sticker-convert 2.3.0__py3-none-any.whl → 2.4.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.
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env python3
2
2
  '''sticker-convert'''
3
- __version__ = '2.3.0'
3
+ __version__ = '2.4.0'
sticker_convert/cli.py CHANGED
@@ -67,11 +67,14 @@ class CLI:
67
67
  'color_min', 'color_max',
68
68
  'duration_min', 'duration_max',
69
69
  'vid_size_max', 'img_size_max')
70
- flags_str = ('vid_format', 'img_format', 'cache_dir', 'scale_filter')
70
+ flags_float = ('fps_power', 'res_power', 'quality_power', 'color_power')
71
+ flags_str = ('vid_format', 'img_format', 'cache_dir', 'scale_filter', 'quantize_method')
71
72
  flags_bool = ('fake_vid')
72
73
  for k, v in self.help['comp'].items():
73
74
  if k in flags_int:
74
75
  keyword_args = {'type': int, 'default': None}
76
+ elif k in flags_float:
77
+ keyword_args = {'type': float, 'default': None}
75
78
  elif k in flags_str:
76
79
  keyword_args = {'default': None}
77
80
  elif k in flags_bool:
@@ -202,7 +205,8 @@ class CLI:
202
205
  },
203
206
  'fps': {
204
207
  'min': self.compression_presets[preset]['fps']['min'] if args.fps_min == None else args.fps_min,
205
- 'max': self.compression_presets[preset]['fps']['max'] if args.fps_max == None else args.fps_max
208
+ 'max': self.compression_presets[preset]['fps']['max'] if args.fps_max == None else args.fps_max,
209
+ 'power': self.compression_presets[preset]['fps']['power'] if args.fps_power == None else args.fps_power,
206
210
  },
207
211
  'res': {
208
212
  'w': {
@@ -212,15 +216,18 @@ class CLI:
212
216
  'h': {
213
217
  'min': self.compression_presets[preset]['res']['h']['min'] if args.res_h_min == None else args.res_h_min,
214
218
  'max': self.compression_presets[preset]['res']['h']['max'] if args.res_h_max == None else args.res_h_max
215
- }
219
+ },
220
+ 'power': self.compression_presets[preset]['res']['power'] if args.res_power == None else args.res_power,
216
221
  },
217
222
  'quality': {
218
223
  'min': self.compression_presets[preset]['quality']['min'] if args.quality_min == None else args.quality_min,
219
- 'max': self.compression_presets[preset]['quality']['max'] if args.quality_max == None else args.quality_max
224
+ 'max': self.compression_presets[preset]['quality']['max'] if args.quality_max == None else args.quality_max,
225
+ 'power': self.compression_presets[preset]['quality']['power'] if args.quality_power == None else args.quality_power,
220
226
  },
221
227
  'color': {
222
228
  'min': self.compression_presets[preset]['color']['min'] if args.color_min == None else args.color_min,
223
- 'max': self.compression_presets[preset]['color']['max'] if args.color_max == None else args.color_max
229
+ 'max': self.compression_presets[preset]['color']['max'] if args.color_max == None else args.color_max,
230
+ 'power': self.compression_presets[preset]['color']['power'] if args.color_power == None else args.color_power,
224
231
  },
225
232
  'duration': {
226
233
  'min': self.compression_presets[preset]['duration']['min'] if args.duration_min == None else args.duration_min,
@@ -229,8 +236,9 @@ class CLI:
229
236
  'steps': self.compression_presets[preset]['steps'] if args.steps == None else args.steps,
230
237
  'fake_vid': self.compression_presets[preset]['fake_vid'] if args.fake_vid == None else args.fake_vid,
231
238
  'cache_dir': args.cache_dir,
232
- 'scale_filter': args.scale_filter,
233
- 'default_emoji': args.default_emoji,
239
+ 'scale_filter': self.compression_presets[preset]['scale_filter'] if args.scale_filter == None else args.scale_filter,
240
+ 'quantize_method': self.compression_presets[preset]['quantize_method'] if args.quantize_method == None else args.quantize_method,
241
+ 'default_emoji': self.compression_presets[preset]['default_emoji'] if args.default_emoji == None else args.default_emoji,
234
242
  'no_compress': args.no_compress,
235
243
  'processes': args.processes if args.processes else math.ceil(cpu_count() / 2)
236
244
  }
@@ -4,15 +4,8 @@ import io
4
4
  from multiprocessing.queues import Queue as QueueType
5
5
  from typing import Optional, Union
6
6
 
7
- import imageio.v3 as iio
8
- from rlottie_python import LottieAnimation # type: ignore
9
- from apngasm_python._apngasm_python import APNGAsm, create_frame_from_rgba
10
7
  import numpy as np
11
8
  from PIL import Image
12
- import av # type: ignore
13
- from av.codec.context import CodecContext # type: ignore
14
- import webp # type: ignore
15
- import oxipng
16
9
 
17
10
  from .utils.media.codec_info import CodecInfo # type: ignore
18
11
  from .utils.files.cache_store import CacheStore # type: ignore
@@ -22,11 +15,26 @@ from .job_option import CompOption
22
15
 
23
16
  def get_step_value(
24
17
  max: Optional[int], min: Optional[int],
25
- step: int, steps: int
26
- ) -> Optional[int]:
18
+ step: int, steps: int,
19
+ power: int = 1,
20
+ even: bool = False
21
+ ) -> Optional[int]:
22
+ # Power should be between -1 and positive infinity
23
+ # Smaller power = More 'importance' of the parameter
24
+ # Power of 1 is linear relationship
25
+ # e.g. fps has lower power -> Try not to reduce it early on
26
+
27
+ if step > 0:
28
+ factor = pow(step / steps, power)
29
+ else:
30
+ factor = 0
27
31
 
28
- if max and min:
29
- return round((max - min) * step / steps + min)
32
+ if max != None and min != None:
33
+ v = round((max - min) * step / steps * factor + min)
34
+ if even == True and v % 2 == 1:
35
+ return v + 1
36
+ else:
37
+ return v
30
38
  else:
31
39
  return None
32
40
 
@@ -89,23 +97,21 @@ class StickerConvert:
89
97
  self.fps = None
90
98
  self.color = None
91
99
 
92
- self.frames_orig = CodecInfo.get_file_frames(self.in_f)
93
- self.fps_orig = CodecInfo.get_file_fps(self.in_f)
94
- self.duration_orig = self.frames_orig / self.fps_orig * 1000
100
+ self.codec_info_orig = CodecInfo(self.in_f)
95
101
 
96
102
  self.tmp_f = None
97
103
  self.result = None
98
104
  self.result_size = 0
99
105
  self.result_step = None
100
106
 
101
- self.apngasm = APNGAsm() # type: ignore[call-arg]
107
+ self.apngasm = None
102
108
 
103
109
  def convert(self) -> tuple[bool, str, Union[None, bytes, str], int]:
104
- if (FormatVerify.check_format(self.in_f, fmt=self.out_f_ext) and
105
- FormatVerify.check_file_res(self.in_f, res=self.opt_comp.res) and
106
- FormatVerify.check_file_fps(self.in_f, fps=self.opt_comp.fps) and
107
- FormatVerify.check_file_size(self.in_f, size=self.opt_comp.size_max) and
108
- FormatVerify.check_duration(self.in_f, duration=self.opt_comp.duration)):
110
+ if (FormatVerify.check_format(self.in_f, fmt=self.out_f_ext, file_info=self.codec_info_orig) and
111
+ FormatVerify.check_file_res(self.in_f, res=self.opt_comp.res, file_info=self.codec_info_orig) and
112
+ FormatVerify.check_file_fps(self.in_f, fps=self.opt_comp.fps, file_info=self.codec_info_orig) and
113
+ FormatVerify.check_file_size(self.in_f, size=self.opt_comp.size_max, file_info=self.codec_info_orig) and
114
+ FormatVerify.check_file_duration(self.in_f, duration=self.opt_comp.duration, file_info=self.codec_info_orig)):
109
115
  self.cb_msg.put(self.MSG_SKIP_COMP.format(self.in_f_name, self.out_f_name))
110
116
 
111
117
  with open(self.in_f, 'rb') as f:
@@ -119,11 +125,11 @@ class StickerConvert:
119
125
  steps_list = []
120
126
  for step in range(self.opt_comp.steps, -1, -1):
121
127
  steps_list.append((
122
- get_step_value(self.opt_comp.res_w_max, self.opt_comp.res_w_min, step, self.opt_comp.steps),
123
- get_step_value(self.opt_comp.res_h_max, self.opt_comp.res_h_min, step, self.opt_comp.steps),
124
- get_step_value(self.opt_comp.quality_max, self.opt_comp.quality_min, step, self.opt_comp.steps),
125
- get_step_value(self.opt_comp.fps_max, self.opt_comp.fps_min, step, self.opt_comp.steps),
126
- get_step_value(self.opt_comp.color_max, self.opt_comp.color_min, step, self.opt_comp.steps)
128
+ get_step_value(self.opt_comp.res_w_max, self.opt_comp.res_w_min, step, self.opt_comp.steps, self.opt_comp.res_power, True),
129
+ get_step_value(self.opt_comp.res_h_max, self.opt_comp.res_h_min, step, self.opt_comp.steps, self.opt_comp.res_power, True),
130
+ get_step_value(self.opt_comp.quality_max, self.opt_comp.quality_min, step, self.opt_comp.steps, self.opt_comp.quality_power),
131
+ get_step_value(self.opt_comp.fps_max, self.opt_comp.fps_min, step, self.opt_comp.steps, self.opt_comp.fps_power),
132
+ get_step_value(self.opt_comp.color_max, self.opt_comp.color_min, step, self.opt_comp.steps, self.opt_comp.color_power)
127
133
  ))
128
134
 
129
135
  step_lower = 0
@@ -141,14 +147,14 @@ class StickerConvert:
141
147
  self.res_w = param[0]
142
148
  self.res_h = param[1]
143
149
  self.quality = param[2]
144
- self.fps = min(param[3], self.fps_orig)
150
+ self.fps = min(param[3], self.codec_info_orig.fps)
145
151
  self.color = param[4]
146
152
 
147
153
  self.tmp_f = io.BytesIO()
148
154
  msg = self.MSG_COMP.format(
149
155
  self.in_f_name, self.out_f_name,
150
156
  self.res_w, self.res_h,
151
- self.quality, self.fps, self.color,
157
+ self.quality, int(self.fps), self.color,
152
158
  step_lower, step_current, step_upper
153
159
  )
154
160
  self.cb_msg.put(msg)
@@ -159,7 +165,7 @@ class StickerConvert:
159
165
 
160
166
  self.tmp_f.seek(0)
161
167
  self.size = self.tmp_f.getbuffer().nbytes
162
- if CodecInfo.is_anim(self.in_f):
168
+ if self.codec_info_orig.is_animated == True:
163
169
  self.size_max = self.opt_comp.size_max_vid
164
170
  else:
165
171
  self.size_max = self.opt_comp.size_max_img
@@ -221,31 +227,35 @@ class StickerConvert:
221
227
 
222
228
  def frames_import(self):
223
229
  if self.in_f_ext in ('.tgs', '.lottie', '.json'):
224
- self.frames_import_lottie()
230
+ self._frames_import_lottie()
231
+ elif self.in_f_ext in ('.webp', '.apng', 'png'):
232
+ # ffmpeg do not support webp decoding (yet)
233
+ # ffmpeg could fail to decode apng if file is buggy
234
+ self._frames_import_pillow()
225
235
  else:
226
- self.frames_import_imageio()
227
-
228
- def frames_import_imageio(self):
229
- # ffmpeg do not support webp decoding (yet)
230
- # ffmpeg could fail to decode apng if file is buggy
231
- if self.in_f_ext in ('.webp', '.apng', 'png'):
232
- for frame in iio.imiter(self.in_f, plugin='pillow', mode='RGBA'):
233
- self.frames_raw.append(frame)
234
- return
235
-
236
+ self._frames_import_pyav()
237
+
238
+ def _frames_import_pillow(self):
239
+ with Image.open(self.in_f, mode='RGBA') as im:
240
+ if 'n_frames'in im.__dir__():
241
+ for i in range(im.n_frames):
242
+ im.seek(i)
243
+ self.frames_raw.append(im.copy().asarray())
244
+ else:
245
+ self.frames_raw.append(im.copy().asarray())
246
+
247
+ def _frames_import_pyav(self):
248
+ import av # type: ignore
249
+ from av.codec.context import CodecContext # type: ignore
250
+
236
251
  # Crashes when handling some webm in yuv420p and convert to rgba
237
252
  # https://github.com/PyAV-Org/PyAV/issues/1166
238
- metadata = iio.immeta(self.in_f, plugin='pyav', exclude_applied=False)
239
- context = None
240
- if metadata.get('video_format') == 'yuv420p':
241
- if metadata.get('codec') == 'vp8':
253
+ with av.open(self.in_f) as container:
254
+ context = container.streams.video[0].codec_context
255
+ if context.name == 'vp8':
242
256
  context = CodecContext.create('libvpx', 'r')
243
- elif metadata.get('codec') == 'vp9':
257
+ elif context.name == 'vp9':
244
258
  context = CodecContext.create('libvpx-vp9', 'r')
245
-
246
- with av.open(self.in_f) as container:
247
- if not context:
248
- context = container.streams.video[0].codec_context
249
259
 
250
260
  for packet in container.demux(container.streams.video):
251
261
  for frame in context.decode(packet):
@@ -288,16 +298,18 @@ class StickerConvert:
288
298
  yuv_array[:, :, 1:] = yuv_array[:, :, 1:].clip(16, 240).astype(yuv_array.dtype) - 128
289
299
 
290
300
  convert = np.array([
291
- [1.164, 0.000, 2.018],
292
- [1.164, -0.813, -0.391],
293
- [1.164, 1.596, 0.000]
301
+ [1.164, 0.000, 1.793],
302
+ [1.164, -0.213, -0.533],
303
+ [1.164, 2.112, 0.000]
294
304
  ])
295
305
  rgb_array = np.matmul(yuv_array, convert.T).clip(0,255).astype('uint8')
296
306
  rgba_array = np.concatenate((rgb_array, a), axis=2)
297
307
 
298
308
  self.frames_raw.append(rgba_array)
299
309
 
300
- def frames_import_lottie(self):
310
+ def _frames_import_lottie(self):
311
+ from rlottie_python import LottieAnimation # type: ignore
312
+
301
313
  if self.in_f_ext == '.tgs':
302
314
  anim = LottieAnimation.from_tgs(self.in_f)
303
315
  else:
@@ -315,39 +327,40 @@ class StickerConvert:
315
327
  def frames_resize(self, frames_in: list[np.ndarray]) -> list[np.ndarray]:
316
328
  frames_out = []
317
329
 
318
- for frame in frames_in:
319
- im = Image.fromarray(frame, 'RGBA')
320
- width, height = im.size
330
+ if self.opt_comp.scale_filter == 'nearest':
331
+ resample = Image.NEAREST
332
+ elif self.opt_comp.scale_filter == 'bilinear':
333
+ resample = Image.BILINEAR
334
+ elif self.opt_comp.scale_filter == 'bicubic':
335
+ resample = Image.BICUBIC
336
+ elif self.opt_comp.scale_filter == 'lanczos':
337
+ resample = Image.LANCZOS
338
+ else:
339
+ resample = Image.LANCZOS
321
340
 
322
- if self.res_w == None:
323
- self.res_w = width
324
- if self.res_h == None:
325
- self.res_h = height
341
+ for frame in frames_in:
342
+ with Image.fromarray(frame, 'RGBA') as im:
343
+ width, height = im.size
326
344
 
327
- if width > height:
328
- width_new = self.res_w
329
- height_new = height * self.res_w // width
330
- else:
331
- height_new = self.res_h
332
- width_new = width * self.res_h // height
333
-
334
- if self.opt_comp.scale_filter == 'nearest':
335
- resample = Image.NEAREST
336
- elif self.opt_comp.scale_filter == 'bilinear':
337
- resample = Image.BILINEAR
338
- elif self.opt_comp.scale_filter == 'bicubic':
339
- resample = Image.BICUBIC
340
- elif self.opt_comp.scale_filter == 'lanczos':
341
- resample = Image.LANCZOS
342
- else:
343
- resample = Image.LANCZOS
345
+ if self.res_w == None:
346
+ self.res_w = width
347
+ if self.res_h == None:
348
+ self.res_h = height
344
349
 
345
- im = im.resize((width_new, height_new), resample=resample)
346
- im_new = Image.new('RGBA', (self.res_w, self.res_h), (0, 0, 0, 0))
347
- im_new.paste(
348
- im, ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2)
349
- )
350
- frames_out.append(np.asarray(im_new))
350
+ if width > height:
351
+ width_new = self.res_w
352
+ height_new = height * self.res_w // width
353
+ else:
354
+ height_new = self.res_h
355
+ width_new = width * self.res_h // height
356
+
357
+ with (im.resize((width_new, height_new), resample=resample) as im_resized,
358
+ Image.new('RGBA', (self.res_w, self.res_h), (0, 0, 0, 0)) as im_new):
359
+
360
+ im_new.paste(
361
+ im_resized, ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2)
362
+ )
363
+ frames_out.append(np.asarray(im_new))
351
364
 
352
365
  return frames_out
353
366
 
@@ -359,15 +372,15 @@ class StickerConvert:
359
372
 
360
373
  # fps_ratio: 1 frame in new anim equal to how many frame in old anim
361
374
  # speed_ratio: How much to speed up / slow down
362
- fps_ratio = self.fps_orig / self.fps
375
+ fps_ratio = self.codec_info_orig.fps / self.fps
363
376
  if (self.opt_comp.duration_min != None and
364
- self.duration_orig < self.opt_comp.duration_min):
377
+ self.codec_info_orig.duration < self.opt_comp.duration_min):
365
378
 
366
- speed_ratio = self.duration_orig / self.opt_comp.duration_min
379
+ speed_ratio = self.codec_info_orig.duration / self.opt_comp.duration_min
367
380
  elif (self.opt_comp.duration_max != None and
368
- self.duration_orig > self.opt_comp.duration_max):
381
+ self.codec_info_orig.duration > self.opt_comp.duration_max):
369
382
 
370
- speed_ratio = self.duration_orig / self.opt_comp.duration_max
383
+ speed_ratio = self.codec_info_orig.duration / self.opt_comp.duration_max
371
384
  else:
372
385
  speed_ratio = 1
373
386
 
@@ -382,25 +395,27 @@ class StickerConvert:
382
395
 
383
396
  def frames_export(self):
384
397
  if self.out_f_ext in ('.apng', '.png') and self.fps:
385
- self.frames_export_apng()
398
+ self._frames_export_apng()
386
399
  elif self.out_f_ext == '.png':
387
- self.frames_export_png()
400
+ self._frames_export_png()
388
401
  elif self.out_f_ext == '.webp' and self.fps:
389
- self.frames_export_webp()
402
+ self._frames_export_webp()
390
403
  elif self.fps:
391
- self.frames_export_pyav()
404
+ self._frames_export_pyav()
392
405
  else:
393
- self.frames_export_pil()
406
+ self._frames_export_pil()
394
407
 
395
- def frames_export_pil(self):
396
- image = Image.fromarray(self.frames_processed[0])
397
- image.save(
398
- self.tmp_f,
399
- format=self.out_f_ext.replace('.', ''),
400
- quality=self.quality
401
- )
408
+ def _frames_export_pil(self):
409
+ with Image.fromarray(self.frames_processed[0]) as im:
410
+ im.save(
411
+ self.tmp_f,
412
+ format=self.out_f_ext.replace('.', ''),
413
+ quality=self.quality
414
+ )
415
+
416
+ def _frames_export_pyav(self):
417
+ import av # type: ignore
402
418
 
403
- def frames_export_pyav(self):
404
419
  options = {}
405
420
 
406
421
  if isinstance(self.quality, int):
@@ -416,10 +431,14 @@ class StickerConvert:
416
431
  codec = 'apng'
417
432
  pixel_format = 'rgba'
418
433
  options['plays'] = '0'
419
- else:
420
- codec = 'vp9'
434
+ elif self.out_f_ext in ('.webp', '.webm', '.mkv'):
435
+ codec = 'libvpx-vp9'
421
436
  pixel_format = 'yuva420p'
422
437
  options['loop'] = '0'
438
+ else:
439
+ codec = 'libx264'
440
+ pixel_format = 'yuv420p'
441
+ options['loop'] = '0'
423
442
 
424
443
  with av.open(self.tmp_f, 'w', format=self.out_f_ext.replace('.', '')) as output:
425
444
  out_stream = output.add_stream(codec, rate=int(self.fps), options=options)
@@ -435,7 +454,9 @@ class StickerConvert:
435
454
  for packet in out_stream.encode():
436
455
  output.mux(packet)
437
456
 
438
- def frames_export_webp(self):
457
+ def _frames_export_webp(self):
458
+ import webp # type: ignore
459
+
439
460
  config = webp.WebPConfig.new(quality=self.quality)
440
461
  enc = webp.WebPAnimEncoder.new(self.res_w, self.res_h)
441
462
  timestamp_ms = 0
@@ -445,26 +466,35 @@ class StickerConvert:
445
466
  timestamp_ms += int(1 / self.fps * 1000)
446
467
  anim_data = enc.assemble(timestamp_ms)
447
468
  self.tmp_f.write(anim_data.buffer())
469
+
470
+ def _frames_export_png(self):
471
+ import oxipng
472
+
473
+ with Image.fromarray(self.frames_processed[0], 'RGBA') as image:
474
+ if self.color and self.color <= 256:
475
+ image_quant = self.quantize(image)
476
+ else:
477
+ image_quant = image.copy()
448
478
 
449
- def frames_export_png(self):
450
- image = Image.fromarray(self.frames_processed[0], 'RGBA')
451
- if self.color and self.color <= 256:
452
- image_quant = image.quantize(colors=self.color, method=2)
453
- else:
454
- image_quant = image
455
479
  with io.BytesIO() as f:
456
480
  image_quant.save(f, format='png')
457
481
  f.seek(0)
458
482
  frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
459
483
  self.tmp_f.write(frame_optimized)
460
484
 
461
- def frames_export_apng(self):
485
+ def _frames_export_apng(self):
486
+ import oxipng
487
+ from apngasm_python._apngasm_python import APNGAsm, create_frame_from_rgba
488
+
462
489
  frames_concat = np.concatenate(self.frames_processed)
463
- image_concat = Image.fromarray(frames_concat, 'RGBA')
464
- if self.color and self.color <= 256:
465
- image_quant = image_concat.quantize(colors=self.color, method=2)
466
- else:
467
- image_quant = image_concat
490
+ with Image.fromarray(frames_concat, 'RGBA') as image_concat:
491
+ if self.color and self.color <= 256:
492
+ image_quant = self.quantize(image_concat)
493
+ else:
494
+ image_quant = image_concat.copy()
495
+
496
+ if self.apngasm == None:
497
+ self.apngasm = APNGAsm()
468
498
 
469
499
  for i in range(0, image_quant.height, self.res_h):
470
500
  with io.BytesIO() as f:
@@ -473,12 +503,13 @@ class StickerConvert:
473
503
  image_cropped.save(f, format='png')
474
504
  f.seek(0)
475
505
  frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
476
- image_final = Image.open(io.BytesIO(frame_optimized)).convert('RGBA')
506
+ with Image.open(io.BytesIO(frame_optimized)) as im:
507
+ image_final = im.convert('RGBA')
477
508
  frame_final = create_frame_from_rgba(
478
509
  np.array(image_final),
479
510
  image_final.width,
480
511
  image_final.height
481
- )
512
+ )
482
513
  frame_final.delay_num = int(1000 / self.fps)
483
514
  frame_final.delay_den = 1000
484
515
  self.apngasm.add_frame(frame_final)
@@ -489,4 +520,35 @@ class StickerConvert:
489
520
  with open(os.path.join(tempdir, f'out{self.out_f_ext}'), 'rb') as f:
490
521
  self.tmp_f.write(f.read())
491
522
 
492
- self.apngasm.reset()
523
+ self.apngasm.reset()
524
+
525
+ def quantize(self, image: Image.Image) -> Image.Image:
526
+ if self.opt_comp.quantize_method == 'imagequant':
527
+ return self._quantize_by_imagequant(image)
528
+ elif self.opt_comp.quantize_method == 'fastoctree':
529
+ return self._quantize_by_fastoctree(image)
530
+ else:
531
+ return image
532
+
533
+ def _quantize_by_imagequant(self, image: Image.Image) -> Image.Image:
534
+ import imagequant
535
+
536
+ dither = 1 - (self.quality - self.opt_comp.quality_min) / (self.opt_comp.quality_max - self.opt_comp.quality_min)
537
+ image_quant = None
538
+ for i in range(self.quality, 101, 5):
539
+ try:
540
+ image_quant = imagequant.quantize_pil_image(
541
+ image,
542
+ dithering_level=dither,
543
+ max_colors=self.color,
544
+ min_quality=self.opt_comp.quality_min,
545
+ max_quality=i
546
+ )
547
+ return image_quant
548
+ except RuntimeError:
549
+ pass
550
+
551
+ return image
552
+
553
+ def _quantize_by_fastoctree(self, image: Image.Image) -> Image.Image:
554
+ return image.quantize(colors=self.color, method=2)
@@ -315,10 +315,14 @@ class DownloadLine(DownloadBase):
315
315
  base_path = os.path.join(self.out_dir, i.replace('-text.png', '.png'))
316
316
  text_path = os.path.join(self.out_dir, i)
317
317
 
318
- base_img = Image.open(base_path).convert('RGBA')
319
- text_img = Image.open(text_path).convert('RGBA')
318
+ with Image.open(base_path) as im:
319
+ base_img = im.convert('RGBA')
320
+
321
+ with Image.open(text_path) as im:
322
+ text_img = im.convert('RGBA')
320
323
 
321
- Image.alpha_composite(base_img, text_img).save(base_path)
324
+ with Image.alpha_composite(base_img, text_img) as im:
325
+ im.save(base_path)
322
326
 
323
327
  os.remove(text_path)
324
328
 
sticker_convert/gui.py CHANGED
@@ -13,7 +13,7 @@ from urllib.parse import urlparse
13
13
  from typing import Optional, Any
14
14
 
15
15
  from PIL import ImageFont
16
- from ttkbootstrap import Window, StringVar, BooleanVar, IntVar # type: ignore
16
+ from ttkbootstrap import Window, StringVar, BooleanVar, IntVar, DoubleVar # type: ignore
17
17
  from ttkbootstrap.dialogs import Messagebox, Querybox # type: ignore
18
18
 
19
19
  from .job import Job # type: ignore
@@ -104,18 +104,22 @@ class GUI(Window):
104
104
  self.fps_min_var = IntVar(self)
105
105
  self.fps_max_var = IntVar(self)
106
106
  self.fps_disable_var = BooleanVar()
107
+ self.fps_power_var = DoubleVar()
107
108
  self.res_w_min_var = IntVar(self)
108
109
  self.res_w_max_var = IntVar(self)
109
110
  self.res_w_disable_var = BooleanVar()
110
111
  self.res_h_min_var = IntVar(self)
111
112
  self.res_h_max_var = IntVar(self)
112
113
  self.res_h_disable_var = BooleanVar()
114
+ self.res_power_var = DoubleVar()
113
115
  self.quality_min_var = IntVar(self)
114
116
  self.quality_max_var = IntVar(self)
115
117
  self.quality_disable_var = BooleanVar()
118
+ self.quality_power_var = DoubleVar()
116
119
  self.color_min_var = IntVar(self)
117
120
  self.color_max_var = IntVar(self)
118
121
  self.color_disable_var = BooleanVar()
122
+ self.color_power_var = DoubleVar()
119
123
  self.duration_min_var = IntVar(self)
120
124
  self.duration_max_var = IntVar(self)
121
125
  self.duration_disable_var = BooleanVar()
@@ -126,6 +130,7 @@ class GUI(Window):
126
130
  self.vid_format_var = StringVar(self)
127
131
  self.fake_vid_var = BooleanVar()
128
132
  self.scale_filter_var = StringVar(self)
133
+ self.quantize_method_var = StringVar(self)
129
134
  self.cache_dir_var = StringVar(self)
130
135
  self.default_emoji_var = StringVar(self)
131
136
  self.steps_var = IntVar(self)
@@ -356,7 +361,8 @@ class GUI(Window):
356
361
  },
357
362
  'fps': {
358
363
  'min': self.fps_min_var.get() if not self.fps_disable_var.get() else None,
359
- 'max': self.fps_max_var.get() if not self.fps_disable_var.get() else None
364
+ 'max': self.fps_max_var.get() if not self.fps_disable_var.get() else None,
365
+ 'power': self.fps_power_var.get()
360
366
  },
361
367
  'res': {
362
368
  'w': {
@@ -366,15 +372,18 @@ class GUI(Window):
366
372
  'h': {
367
373
  'min': self.res_h_min_var.get() if not self.res_h_disable_var.get() else None,
368
374
  'max': self.res_h_max_var.get() if not self.res_h_disable_var.get() else None
369
- }
375
+ },
376
+ 'power': self.res_power_var.get()
370
377
  },
371
378
  'quality': {
372
379
  'min': self.quality_min_var.get() if not self.quality_disable_var.get() else None,
373
- 'max': self.quality_max_var.get() if not self.quality_disable_var.get() else None
380
+ 'max': self.quality_max_var.get() if not self.quality_disable_var.get() else None,
381
+ 'power': self.quality_power_var.get()
374
382
  },
375
383
  'color': {
376
384
  'min': self.color_min_var.get() if not self.color_disable_var.get() else None,
377
- 'max': self.color_max_var.get() if not self.color_disable_var.get() else None
385
+ 'max': self.color_max_var.get() if not self.color_disable_var.get() else None,
386
+ 'power': self.color_power_var.get()
378
387
  },
379
388
  'duration': {
380
389
  'min': self.duration_min_var.get() if not self.duration_disable_var.get() else None,
@@ -383,6 +392,7 @@ class GUI(Window):
383
392
  'steps': self.steps_var.get(),
384
393
  'fake_vid': self.fake_vid_var.get(),
385
394
  'scale_filter': self.scale_filter_var.get(),
395
+ 'quantize_method': self.quantize_method_var.get(),
386
396
  'cache_dir': self.cache_dir_var.get() if self.cache_dir_var.get() != '' else None,
387
397
  'default_emoji': self.default_emoji_var.get(),
388
398
  'no_compress': self.no_compress_var.get(),
@@ -66,14 +66,18 @@ class CompFrame(LabelFrame):
66
66
 
67
67
  self.gui.fps_min_var.set(self.gui.compression_presets[selection]['fps']['min'])
68
68
  self.gui.fps_max_var.set(self.gui.compression_presets[selection]['fps']['max'])
69
+ self.gui.fps_power_var.set(self.gui.compression_presets[selection]['fps']['power'])
69
70
  self.gui.res_w_min_var.set(self.gui.compression_presets[selection]['res']['w']['min'])
70
71
  self.gui.res_w_max_var.set(self.gui.compression_presets[selection]['res']['w']['max'])
71
72
  self.gui.res_h_min_var.set(self.gui.compression_presets[selection]['res']['h']['min'])
72
73
  self.gui.res_h_max_var.set(self.gui.compression_presets[selection]['res']['h']['max'])
74
+ self.gui.res_power_var.set(self.gui.compression_presets[selection]['res']['power'])
73
75
  self.gui.quality_min_var.set(self.gui.compression_presets[selection]['quality']['min'])
74
76
  self.gui.quality_max_var.set(self.gui.compression_presets[selection]['quality']['max'])
77
+ self.gui.quality_power_var.set(self.gui.compression_presets[selection]['quality']['power'])
75
78
  self.gui.color_min_var.set(self.gui.compression_presets[selection]['color']['min'])
76
79
  self.gui.color_max_var.set(self.gui.compression_presets[selection]['color']['max'])
80
+ self.gui.color_power_var.set(self.gui.compression_presets[selection]['color']['power'])
77
81
  self.gui.duration_min_var.set(self.gui.compression_presets[selection]['duration']['min'])
78
82
  self.gui.duration_max_var.set(self.gui.compression_presets[selection]['duration']['max'])
79
83
  self.gui.img_size_max_var.set(self.gui.compression_presets[selection]['size_max']['img'])
@@ -82,6 +86,7 @@ class CompFrame(LabelFrame):
82
86
  self.gui.vid_format_var.set(self.gui.compression_presets[selection]['format']['vid'])
83
87
  self.gui.fake_vid_var.set(self.gui.compression_presets[selection]['fake_vid'])
84
88
  self.gui.scale_filter_var.set(self.gui.compression_presets[selection]['scale_filter'])
89
+ self.gui.quantize_method_var.set(self.gui.compression_presets[selection]['quantize_method'])
85
90
  self.gui.default_emoji_var.set(self.gui.compression_presets[selection]['default_emoji'])
86
91
  self.gui.steps_var.set(self.gui.compression_presets[selection]['steps'])
87
92