fotolab 0.29.1__py3-none-any.whl → 0.30.0__py3-none-any.whl

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/__init__.py CHANGED
@@ -24,7 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from PIL import Image
26
26
 
27
- __version__ = "0.29.1"
27
+ __version__ = "0.30.0"
28
28
 
29
29
  log = logging.getLogger(__name__)
30
30
 
fotolab/cli.py CHANGED
@@ -15,9 +15,9 @@
15
15
 
16
16
  """A console program to manipulate photos.
17
17
 
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
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)
@@ -26,6 +26,21 @@ from fotolab import _open_image
26
26
  log = logging.getLogger(__name__)
27
27
 
28
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
+
29
44
  def build_subparser(subparsers) -> None:
30
45
  """Build the subparser."""
31
46
  animate_parser = subparsers.add_parser("animate", help="animate an image")
@@ -56,9 +71,9 @@ def build_subparser(subparsers) -> None:
56
71
  "-d",
57
72
  "--duration",
58
73
  dest="duration",
59
- type=int,
74
+ type=_validate_duration,
60
75
  default=2500,
61
- help="set the duration in milliseconds (default: '%(default)s')",
76
+ help="set the duration in milliseconds (must be a positive integer, default: '%(default)s')",
62
77
  metavar="DURATION",
63
78
  )
64
79
 
@@ -81,6 +96,34 @@ def build_subparser(subparsers) -> None:
81
96
  help="open the image using default program (default: '%(default)s')",
82
97
  )
83
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
+
84
127
  animate_parser.add_argument(
85
128
  "-od",
86
129
  "--output-dir",
@@ -101,31 +144,50 @@ def run(args: argparse.Namespace) -> None:
101
144
  """
102
145
  log.debug(args)
103
146
 
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
- )
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()
129
191
 
130
192
  if args.open:
131
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(f"invalid float value: '{value}'") from e
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, but safe)
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 coordinates
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
- # Max radius is half the cell size. Scale by brightness (0-255).
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
- # TODO: Add error handling for file open
78
- # TODO: Use context manager `with Image.open(...)`
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
- exif_tags = extract_exif_tags(image, args.sort)
79
+ if not exif_tags:
80
+ print("No metadata found!")
81
+ return
82
82
 
83
- if not exif_tags:
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
- # TODO: Add error handling for missing key
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,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: fotolab
3
- Version: 0.29.1
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
- [-od OUTPUT_DIR]
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 (default: '2500')
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
  ```
@@ -1,21 +1,21 @@
1
- fotolab/__init__.py,sha256=KwMFpmP-DDvkiBIx6zr9XGWSONGzZo5Yr0aHKjYUmlg,3262
1
+ fotolab/__init__.py,sha256=RNGEVnKSQe-y5yy2fi65zOV5cJGZk9tw9o7id0xBMX4,3262
2
2
  fotolab/__main__.py,sha256=aboOURPs_snOXTEWYR0q8oq1UTY9e-NxCd1j33V0wHI,833
3
- fotolab/cli.py,sha256=NH_u73SJhzwDJSRqpi1I4dc8wBfcqbDxNne3cio163A,4411
3
+ fotolab/cli.py,sha256=oFiQXmsu3wIsM_DpZnL4B94sAoB62L16Am-cjxGmosY,4406
4
4
  fotolab/subcommands/__init__.py,sha256=l3DlIaJ3u3jGjnC1H1yV8LZ_nPqOLJ6gikD4BCaMAQ0,1129
5
- fotolab/subcommands/animate.py,sha256=OcS6Q6JM1TW6HF0dSgBsV9mpEGwDMcJlQssupkf0_ZA,3393
5
+ fotolab/subcommands/animate.py,sha256=vmviz3cLnHfVENxFKiTimhx8nmbGbzumOP6dUd_UiUI,5524
6
6
  fotolab/subcommands/auto.py,sha256=ia-xegV1Z4HvYsbKgmTzf1NfNFdTDPWfZe7vQ1_90Ik,2425
7
7
  fotolab/subcommands/border.py,sha256=BS3BHytdWiNumxdKulKYK-WigbsKtPxECdvInUhUjSQ,4608
8
- fotolab/subcommands/contrast.py,sha256=ZHTvAJhRYZjNTrkHRZq3O6wba3LS7QjH1BfiGf4ZvGY,3005
8
+ fotolab/subcommands/contrast.py,sha256=fcXmHnxDw74j5ZUDQ5cwWh0N4tpyqqvEjymnpITgrEk,3027
9
9
  fotolab/subcommands/env.py,sha256=QoxRvzZKgmoHTUxDV4QYhdChCpMWs5TbXFY_qIpIQpE,1469
10
- fotolab/subcommands/halftone.py,sha256=YOGtdl6N3aC4E47ac1rGzHU0HczVsujKNIBMyiRf2-c,6681
11
- fotolab/subcommands/info.py,sha256=EYuU06-rrnZZMY0XKwaIiqLypbd6x9KmCydiB1UhtTU,4010
10
+ fotolab/subcommands/halftone.py,sha256=lt6RV0OuZkGs1LigTC1EcCCY42CocPFHWaJTDjJ5LFM,6693
11
+ fotolab/subcommands/info.py,sha256=vie578cEOesAyHohm0xCWwZBqXpblYJtdHGSyBgxsoU,3581
12
12
  fotolab/subcommands/montage.py,sha256=d_3EcyRSFS8fKkczlHO8IRP-mrKhQUtkQndjfd0MKsc,2566
13
13
  fotolab/subcommands/resize.py,sha256=UOb2rg_5ArRj0gxPTDOww_ix3tqJRC0W5b_S-Lt1G4w,5444
14
14
  fotolab/subcommands/rotate.py,sha256=uBFjHyjiBSQLtrtH1p9myODIHUDr1gkL4PpU-6Y1Ofo,2575
15
15
  fotolab/subcommands/sharpen.py,sha256=YNho2IPbc-lPvSy3Bsjehc2JOEy27LPqFSGRULs9MyY,3492
16
16
  fotolab/subcommands/watermark.py,sha256=qRGUp1Lc22fZSJDFRqQGPiz8RSB293ebvOVTdsDLUE4,10351
17
- fotolab-0.29.1.dist-info/entry_points.txt,sha256=mvw7AY_yZkIyjAxPtHNed9X99NZeLnMxEeAfEJUbrCM,44
18
- fotolab-0.29.1.dist-info/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
19
- fotolab-0.29.1.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
20
- fotolab-0.29.1.dist-info/METADATA,sha256=0jUgjFRshumTtyFXLKe6_sQVnpyGLNez-Fl-KB5vHMk,12980
21
- fotolab-0.29.1.dist-info/RECORD,,
17
+ fotolab-0.30.0.dist-info/entry_points.txt,sha256=mvw7AY_yZkIyjAxPtHNed9X99NZeLnMxEeAfEJUbrCM,44
18
+ fotolab-0.30.0.dist-info/LICENSE.md,sha256=tGtFDwxWTjuR9syrJoSv1Hiffd2u8Tu8cYClfrXS_YU,31956
19
+ fotolab-0.30.0.dist-info/WHEEL,sha256=EZbGkh7Ie4PoZfRQ8I0ZuP9VklN_TvcZ6DSE5Uar4z4,81
20
+ fotolab-0.30.0.dist-info/METADATA,sha256=tPOkSTJz_hy0seegQ60vrMKDyAcbwZWcIBwPl0ZU5Ac,13395
21
+ fotolab-0.30.0.dist-info/RECORD,,