sticker-convert 2.3.1__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.
- sticker_convert/__init__.py +1 -1
- sticker_convert/cli.py +15 -7
- sticker_convert/converter.py +177 -115
- sticker_convert/downloaders/download_line.py +7 -3
- sticker_convert/gui.py +15 -5
- sticker_convert/gui_components/frames/comp_frame.py +5 -0
- sticker_convert/gui_components/windows/advanced_compression_window.py +55 -15
- sticker_convert/job.py +20 -0
- sticker_convert/job_option.py +13 -4
- sticker_convert/resources/compression.json +92 -42
- sticker_convert/resources/help.json +5 -0
- sticker_convert/uploaders/compress_wastickers.py +2 -1
- sticker_convert/uploaders/xcode_imessage.py +2 -1
- sticker_convert/utils/files/sanitize_filename.py +51 -0
- sticker_convert/utils/media/codec_info.py +185 -122
- sticker_convert/utils/media/format_verify.py +80 -79
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/METADATA +25 -8
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/RECORD +22 -21
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/LICENSE +0 -0
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/WHEEL +0 -0
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/entry_points.txt +0 -0
- {sticker_convert-2.3.1.dist-info → sticker_convert-2.4.0.dist-info}/top_level.txt +0 -0
sticker_convert/__init__.py
CHANGED
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
|
-
|
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
|
-
'
|
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
|
}
|
sticker_convert/converter.py
CHANGED
@@ -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
|
-
|
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
|
-
|
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.
|
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 =
|
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.
|
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.
|
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
|
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.
|
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.
|
227
|
-
|
228
|
-
def
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
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
|
-
|
239
|
-
|
240
|
-
|
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
|
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):
|
@@ -297,7 +307,9 @@ class StickerConvert:
|
|
297
307
|
|
298
308
|
self.frames_raw.append(rgba_array)
|
299
309
|
|
300
|
-
def
|
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
|
-
|
319
|
-
|
320
|
-
|
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
|
-
|
323
|
-
|
324
|
-
|
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
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
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
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
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.
|
375
|
+
fps_ratio = self.codec_info_orig.fps / self.fps
|
363
376
|
if (self.opt_comp.duration_min != None and
|
364
|
-
self.
|
377
|
+
self.codec_info_orig.duration < self.opt_comp.duration_min):
|
365
378
|
|
366
|
-
speed_ratio = self.
|
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.
|
381
|
+
self.codec_info_orig.duration > self.opt_comp.duration_max):
|
369
382
|
|
370
|
-
speed_ratio = self.
|
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.
|
398
|
+
self._frames_export_apng()
|
386
399
|
elif self.out_f_ext == '.png':
|
387
|
-
self.
|
400
|
+
self._frames_export_png()
|
388
401
|
elif self.out_f_ext == '.webp' and self.fps:
|
389
|
-
self.
|
402
|
+
self._frames_export_webp()
|
390
403
|
elif self.fps:
|
391
|
-
self.
|
404
|
+
self._frames_export_pyav()
|
392
405
|
else:
|
393
|
-
self.
|
406
|
+
self._frames_export_pil()
|
394
407
|
|
395
|
-
def
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
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
|
-
|
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
|
-
|
319
|
-
|
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)
|
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
|
|