fotolab 0.29.1__tar.gz → 0.30.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.1 → fotolab-0.30.0}/.pre-commit-config.yaml +3 -3
- {fotolab-0.29.1 → fotolab-0.30.0}/CHANGELOG.md +17 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/PKG-INFO +10 -3
- {fotolab-0.29.1 → fotolab-0.30.0}/Pipfile.lock +0 -4
- {fotolab-0.29.1 → fotolab-0.30.0}/README.md +9 -2
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/__init__.py +1 -1
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/cli.py +4 -4
- fotolab-0.30.0/fotolab/subcommands/animate.py +193 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/contrast.py +3 -1
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/halftone.py +6 -4
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/info.py +7 -20
- {fotolab-0.29.1 → fotolab-0.30.0}/noxfile.py +1 -0
- fotolab-0.29.1/fotolab/subcommands/animate.py +0 -131
- {fotolab-0.29.1 → fotolab-0.30.0}/.coveragerc +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/.gitignore +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/.python-version +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/CONTRIBUTING.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/LICENSE.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/Pipfile +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/Makefile +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/make.bat +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/CHANGELOG.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/CONTRIBUTING.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/LICENSE.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/README.md +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/_static/logo.jpg +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/conf.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/docs/source/index.rst +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/__main__.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/__init__.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/auto.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/border.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/env.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/montage.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/resize.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/rotate.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/sharpen.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/fotolab/subcommands/watermark.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/generate +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/pyproject.toml +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/tests/__init__.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/tests/conftest.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/tests/test_env_subcommand.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.0}/tests/test_help_flag.py +0 -0
- {fotolab-0.29.1 → fotolab-0.30.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,6 +7,23 @@ and this project adheres to [0-based versioning](https://0ver.org/).
|
|
7
7
|
|
8
8
|
## [Unreleased]
|
9
9
|
|
10
|
+
## v0.30.0 (2025-05-18)
|
11
|
+
|
12
|
+
- Add WEBP `quality`, `lossless`, and `method` args
|
13
|
+
- Bump Python version for `pre-commit` check
|
14
|
+
- Close image files after creating animation
|
15
|
+
- Update help message generated by latest Python to readme
|
16
|
+
- Update help message in readme
|
17
|
+
- Validate duration for animate command
|
18
|
+
|
19
|
+
## v0.29.2 (2025-05-11)
|
20
|
+
|
21
|
+
- Bump deps
|
22
|
+
- Code format
|
23
|
+
- Improve info subcommand by adding error handling for missing keys
|
24
|
+
- Remove default value for image filename argument in info subcommand
|
25
|
+
- Use context manager to open image and remove redundant image close.
|
26
|
+
|
10
27
|
## v0.29.1 (2025-05-04)
|
11
28
|
|
12
29
|
- Improve camera metadata handling in watermark subcommand.
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: fotolab
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.30.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
|
```
|
@@ -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)
|
@@ -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"
|
@@ -141,11 +141,13 @@ def _draw_halftone_dot(
|
|
141
141
|
x = min(int(cell.col * cell.cellsize + cell.cellsize / 2), img_width - 1)
|
142
142
|
y = min(int(cell.row * cell.cellsize + cell.cellsize / 2), img_height - 1)
|
143
143
|
|
144
|
-
# Ensure coordinates are non-negative (shouldn't happen with current logic,
|
144
|
+
# Ensure coordinates are non-negative (shouldn't happen with current logic,
|
145
|
+
# but safe)
|
145
146
|
x = max(0, x)
|
146
147
|
y = max(0, y)
|
147
148
|
|
148
|
-
# Get pixel value (brightness or color) from the source image using clamped
|
149
|
+
# Get pixel value (brightness or color) from the source image using clamped
|
150
|
+
# coordinates
|
149
151
|
pixel_value = source_image.getpixel((x, y))
|
150
152
|
|
151
153
|
if grayscale:
|
@@ -160,8 +162,8 @@ def _draw_halftone_dot(
|
|
160
162
|
)
|
161
163
|
dot_fill = pixel_value # Use original color for color dots
|
162
164
|
|
163
|
-
# Calculate dot radius relative to cell size based on brightness
|
164
|
-
#
|
165
|
+
# Calculate dot radius relative to cell size based on brightness Max radius
|
166
|
+
# is half the cell size. Scale by brightness (0-255).
|
165
167
|
dot_radius = (brightness / 255.0) * (cell.cellsize / 2)
|
166
168
|
|
167
169
|
# Draw the dot
|
@@ -33,7 +33,6 @@ def build_subparser(subparsers) -> None:
|
|
33
33
|
dest="image_filename",
|
34
34
|
help="set the image filename",
|
35
35
|
type=str,
|
36
|
-
default=None,
|
37
36
|
metavar="IMAGE_FILENAME",
|
38
37
|
)
|
39
38
|
|
@@ -74,29 +73,22 @@ def run(args: argparse.Namespace) -> None:
|
|
74
73
|
"""
|
75
74
|
log.debug(args)
|
76
75
|
|
77
|
-
|
78
|
-
|
79
|
-
image = Image.open(args.image_filename)
|
76
|
+
with Image.open(args.image_filename) as image:
|
77
|
+
exif_tags = extract_exif_tags(image, args.sort)
|
80
78
|
|
81
|
-
|
79
|
+
if not exif_tags:
|
80
|
+
print("No metadata found!")
|
81
|
+
return
|
82
82
|
|
83
|
-
|
84
|
-
print("No metadata found!")
|
85
|
-
# Close the image if opened outside a 'with' block
|
86
|
-
image.close()
|
87
|
-
return
|
88
|
-
|
89
|
-
output_info = []
|
83
|
+
output_info = []
|
90
84
|
specific_info_requested = False
|
91
85
|
|
92
86
|
if args.camera:
|
93
87
|
specific_info_requested = True
|
94
|
-
# TODO: Add error handling for missing keys
|
95
88
|
output_info.append(camera_metadata(exif_tags))
|
96
89
|
|
97
90
|
if args.datetime:
|
98
91
|
specific_info_requested = True
|
99
|
-
# TODO: Add error handling for missing keys
|
100
92
|
output_info.append(datetime(exif_tags))
|
101
93
|
|
102
94
|
if specific_info_requested:
|
@@ -107,9 +99,6 @@ def run(args: argparse.Namespace) -> None:
|
|
107
99
|
for tag_name, tag_value in exif_tags.items():
|
108
100
|
print(f"{tag_name:<{tag_name_width}}: {tag_value}")
|
109
101
|
|
110
|
-
# Close the image if opened outside a 'with' block
|
111
|
-
image.close()
|
112
|
-
|
113
102
|
|
114
103
|
def extract_exif_tags(image: Image.Image, sort: bool = False) -> dict:
|
115
104
|
"""Extract Exif metadata from image."""
|
@@ -135,13 +124,11 @@ def extract_exif_tags(image: Image.Image, sort: bool = False) -> dict:
|
|
135
124
|
|
136
125
|
def datetime(exif_tags: dict):
|
137
126
|
"""Extract datetime metadata."""
|
138
|
-
|
139
|
-
return exif_tags["DateTime"]
|
127
|
+
return exif_tags.get("DateTime", "Not available")
|
140
128
|
|
141
129
|
|
142
130
|
def camera_metadata(exif_tags: dict):
|
143
131
|
"""Extract camera and model metadata."""
|
144
|
-
# TODO: Add error handling for missing keys
|
145
132
|
make = exif_tags.get("Make", "")
|
146
133
|
model = exif_tags.get("Model", "")
|
147
134
|
metadata = f"{make} {model}"
|
@@ -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
|
File without changes
|
File without changes
|