sticker-convert 2.1.6__py3-none-any.whl → 2.1.7__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/__main__.py +7 -4
- sticker_convert/cli.py +39 -31
- sticker_convert/converter.py +432 -0
- sticker_convert/downloaders/download_base.py +40 -16
- sticker_convert/downloaders/download_kakao.py +103 -136
- sticker_convert/downloaders/download_line.py +16 -6
- sticker_convert/downloaders/download_signal.py +48 -32
- sticker_convert/downloaders/download_telegram.py +71 -26
- sticker_convert/gui.py +78 -129
- sticker_convert/{gui_frames → gui_components/frames}/comp_frame.py +2 -3
- sticker_convert/{gui_frames → gui_components/frames}/config_frame.py +3 -4
- sticker_convert/{gui_frames → gui_components/frames}/control_frame.py +2 -2
- sticker_convert/{gui_frames → gui_components/frames}/cred_frame.py +4 -4
- sticker_convert/{gui_frames → gui_components/frames}/input_frame.py +4 -4
- sticker_convert/{gui_frames → gui_components/frames}/output_frame.py +3 -3
- sticker_convert/{gui_frames → gui_components/frames}/progress_frame.py +1 -1
- sticker_convert/{utils → gui_components}/gui_utils.py +38 -21
- sticker_convert/{gui_windows → gui_components/windows}/advanced_compression_window.py +3 -2
- sticker_convert/{gui_windows → gui_components/windows}/base_window.py +3 -2
- sticker_convert/{gui_windows → gui_components/windows}/kakao_get_auth_window.py +3 -3
- sticker_convert/{gui_windows → gui_components/windows}/line_get_auth_window.py +2 -2
- sticker_convert/{gui_windows → gui_components/windows}/signal_get_auth_window.py +2 -2
- sticker_convert/{flow.py → job.py} +91 -102
- sticker_convert/job_option.py +301 -0
- sticker_convert/resources/compression.json +1 -1
- sticker_convert/uploaders/compress_wastickers.py +95 -74
- sticker_convert/uploaders/upload_base.py +16 -4
- sticker_convert/uploaders/upload_signal.py +100 -62
- sticker_convert/uploaders/upload_telegram.py +168 -128
- sticker_convert/uploaders/xcode_imessage.py +202 -132
- sticker_convert/{auth → utils/auth}/get_kakao_auth.py +7 -5
- sticker_convert/{auth → utils/auth}/get_line_auth.py +3 -3
- sticker_convert/{auth → utils/auth}/get_signal_auth.py +1 -1
- sticker_convert/utils/fake_cb_msg.py +5 -2
- sticker_convert/utils/{cache_store.py → files/cache_store.py} +7 -3
- sticker_convert/utils/files/dir_utils.py +64 -0
- sticker_convert/utils/{json_manager.py → files/json_manager.py} +5 -4
- sticker_convert/utils/files/metadata_handler.py +226 -0
- sticker_convert/utils/files/run_bin.py +58 -0
- sticker_convert/utils/{apple_png_normalize.py → media/apple_png_normalize.py} +23 -20
- sticker_convert/utils/{codec_info.py → media/codec_info.py} +41 -35
- sticker_convert/utils/media/decrypt_kakao.py +68 -0
- sticker_convert/utils/media/format_verify.py +184 -0
- sticker_convert/utils/url_detect.py +16 -14
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/METADATA +8 -9
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/RECORD +52 -50
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/WHEEL +1 -1
- sticker_convert/utils/converter.py +0 -407
- sticker_convert/utils/curr_dir.py +0 -70
- sticker_convert/utils/format_verify.py +0 -188
- sticker_convert/utils/metadata_handler.py +0 -190
- sticker_convert/utils/run_bin.py +0 -46
- /sticker_convert/{gui_frames → gui_components/frames}/right_clicker.py +0 -0
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/LICENSE +0 -0
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/entry_points.txt +0 -0
- {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/top_level.txt +0 -0
@@ -1,407 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
import os
|
3
|
-
import io
|
4
|
-
from multiprocessing.queues import Queue as QueueType
|
5
|
-
from typing import Optional, Union
|
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
|
-
import numpy as np
|
11
|
-
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
|
-
|
17
|
-
from .codec_info import CodecInfo # type: ignore
|
18
|
-
from .cache_store import CacheStore # type: ignore
|
19
|
-
from .format_verify import FormatVerify # type: ignore
|
20
|
-
from .fake_cb_msg import FakeCbMsg # type: ignore
|
21
|
-
|
22
|
-
def get_step_value(max: int, min: int, step: int, steps: int) -> Optional[int]:
|
23
|
-
if max and min:
|
24
|
-
return round((max - min) * step / steps + min)
|
25
|
-
else:
|
26
|
-
return None
|
27
|
-
|
28
|
-
class StickerConvert:
|
29
|
-
def __init__(self, in_f: Union[str, list[str, io.BytesIO]], out_f: str, opt_comp: dict, cb_msg=print):
|
30
|
-
if type(cb_msg) != QueueType:
|
31
|
-
cb_msg = FakeCbMsg(cb_msg)
|
32
|
-
|
33
|
-
if type(in_f) == str:
|
34
|
-
self.in_f = in_f
|
35
|
-
self.in_f_name = os.path.split(self.in_f)[1]
|
36
|
-
self.in_f_ext = CodecInfo.get_file_ext(self.in_f)
|
37
|
-
else:
|
38
|
-
self.in_f = in_f[1]
|
39
|
-
self.in_f_name = os.path.split(in_f[0])[1]
|
40
|
-
self.in_f_ext = CodecInfo.get_file_ext(in_f[0])
|
41
|
-
|
42
|
-
self.out_f = out_f
|
43
|
-
self.out_f_name = os.path.split(self.out_f)[1]
|
44
|
-
if os.path.splitext(out_f)[0] not in ('null', 'bytes'):
|
45
|
-
self.out_f_ext = CodecInfo.get_file_ext(self.out_f)
|
46
|
-
else:
|
47
|
-
self.out_f_ext = os.path.splitext(out_f)[1]
|
48
|
-
|
49
|
-
self.cb_msg = cb_msg
|
50
|
-
self.frames_raw: list[np.ndarray] = []
|
51
|
-
self.frames_processed: list[np.ndarray] = []
|
52
|
-
self.opt_comp = opt_comp
|
53
|
-
self.preset = opt_comp.get('preset')
|
54
|
-
|
55
|
-
self.size_max = opt_comp.get('size_max') if type(opt_comp.get('size_max')) == int else None
|
56
|
-
self.size_max_img = opt_comp.get('size_max', {}).get('img') if not self.size_max else self.size_max
|
57
|
-
self.size_max_vid = opt_comp.get('size_max', {}).get('vid') if not self.size_max else self.size_max
|
58
|
-
|
59
|
-
self.format = opt_comp.get('format') if type(opt_comp.get('format')) == str else None
|
60
|
-
self.format_img = opt_comp.get('format', {}).get('img') if not self.format else self.format
|
61
|
-
self.format_vid = opt_comp.get('format', {}).get('vid') if not self.format else self.format
|
62
|
-
|
63
|
-
self.fps = opt_comp.get('fps') if type(opt_comp.get('fps')) == int else None
|
64
|
-
self.fps_min = opt_comp.get('fps', {}).get('min') if not self.fps else self.fps
|
65
|
-
self.fps_max = opt_comp.get('fps', {}).get('max') if not self.fps else self.fps
|
66
|
-
|
67
|
-
self.res_w = opt_comp.get('res', {}).get('w') if type(opt_comp.get('res', {}).get('w')) == int else None
|
68
|
-
self.res_w_min = opt_comp.get('res', {}).get('w', {}).get('min') if not self.res_w else self.res_w
|
69
|
-
self.res_w_max = opt_comp.get('res', {}).get('w', {}).get('max') if not self.res_w else self.res_w
|
70
|
-
|
71
|
-
self.res_h = opt_comp.get('res', {}).get('h') if type(opt_comp.get('res', {}).get('h')) == int else None
|
72
|
-
self.res_h_min = opt_comp.get('res', {}).get('h', {}).get('min') if not self.res_h else self.res_h
|
73
|
-
self.res_h_max = opt_comp.get('res', {}).get('h', {}).get('max') if not self.res_h else self.res_h
|
74
|
-
|
75
|
-
self.quality = opt_comp.get('quality') if type(opt_comp.get('quality')) == int else None
|
76
|
-
self.quality_min = opt_comp.get('quality', {}).get('min') if not self.quality else self.quality
|
77
|
-
self.quality_max = opt_comp.get('quality', {}).get('max') if not self.quality else self.quality
|
78
|
-
|
79
|
-
self.color = opt_comp.get('color') if type(opt_comp.get('color')) == int else None
|
80
|
-
self.color_min = opt_comp.get('color', {}).get('min') if not self.color else self.color
|
81
|
-
self.color_max = opt_comp.get('color', {}).get('max') if not self.color else self.color
|
82
|
-
|
83
|
-
self.duration = opt_comp.get('duration') if type(opt_comp.get('duration')) == int else None
|
84
|
-
self.duration_min = opt_comp.get('duration', {}).get('min') if not self.duration else self.duration
|
85
|
-
self.duration_max = opt_comp.get('duration', {}).get('max') if not self.duration else self.duration
|
86
|
-
|
87
|
-
if not self.size_max and not self.size_max_img and not self.size_max_vid:
|
88
|
-
self.steps = 1
|
89
|
-
else:
|
90
|
-
self.steps = opt_comp.get('steps') if opt_comp.get('steps') else 1
|
91
|
-
self.fake_vid = opt_comp.get('fake_vid')
|
92
|
-
self.cache_dir = opt_comp.get('cache_dir')
|
93
|
-
|
94
|
-
self.tmp_f = None
|
95
|
-
self.tmp_fs: list[bytes] = []
|
96
|
-
|
97
|
-
self.apngasm = APNGAsm() # type: ignore[call-arg]
|
98
|
-
|
99
|
-
def convert(self) -> tuple[bool, str, Union[None, bytes, str], int]:
|
100
|
-
if (FormatVerify.check_format(self.in_f, format=self.out_f_ext) and
|
101
|
-
FormatVerify.check_file_res(self.in_f, res=self.opt_comp.get('res')) and
|
102
|
-
FormatVerify.check_file_fps(self.in_f, fps=self.opt_comp.get('fps')) and
|
103
|
-
FormatVerify.check_file_size(self.in_f, size=self.opt_comp.get('size_max')) and
|
104
|
-
FormatVerify.check_duration(self.in_f, duration=self.opt_comp.get('duration'))):
|
105
|
-
self.cb_msg.put(f'[S] Compatible file found, skip compress and just copy {self.in_f_name} -> {self.out_f_name}')
|
106
|
-
|
107
|
-
with open(self.in_f, 'rb') as f:
|
108
|
-
self.write_out(f.read())
|
109
|
-
return True, self.in_f, self.out_f, os.path.getsize(self.in_f)
|
110
|
-
|
111
|
-
self.cb_msg.put(f'[I] Start compressing {self.in_f_name} -> {self.out_f_name}')
|
112
|
-
|
113
|
-
steps_list = []
|
114
|
-
for step in range(self.steps, -1, -1):
|
115
|
-
steps_list.append((
|
116
|
-
get_step_value(self.res_w_max, self.res_w_min, step, self.steps),
|
117
|
-
get_step_value(self.res_h_max, self.res_h_min, step, self.steps),
|
118
|
-
get_step_value(self.quality_max, self.quality_min, step, self.steps),
|
119
|
-
get_step_value(self.fps_max, self.fps_min, step, self.steps),
|
120
|
-
get_step_value(self.color_max, self.color_min, step, self.steps)
|
121
|
-
))
|
122
|
-
self.tmp_fs = [None] * (self.steps + 1)
|
123
|
-
|
124
|
-
step_lower = 0
|
125
|
-
step_upper = self.steps
|
126
|
-
|
127
|
-
if self.size_max_vid == None and self.size_max_img == None:
|
128
|
-
# No limit to size, create the best quality result
|
129
|
-
step_current = 0
|
130
|
-
else:
|
131
|
-
step_current = round((step_lower + step_upper) / 2)
|
132
|
-
|
133
|
-
self.frames_import()
|
134
|
-
while True:
|
135
|
-
param = steps_list[step_current]
|
136
|
-
self.res_w = param[0]
|
137
|
-
self.res_h = param[1]
|
138
|
-
self.quality = param[2]
|
139
|
-
self.fps = param[3]
|
140
|
-
self.color = param[4]
|
141
|
-
|
142
|
-
self.tmp_f = io.BytesIO()
|
143
|
-
self.cb_msg.put(f'[C] Compressing {self.in_f_name} -> {self.out_f_name} res={self.res_w}x{self.res_h}, quality={self.quality}, fps={self.fps}, color={self.color} (step {step_lower}-{step_current}-{step_upper})')
|
144
|
-
|
145
|
-
self.frames_processed = self.frames_drop(self.frames_raw)
|
146
|
-
self.frames_processed = self.frames_resize(self.frames_processed)
|
147
|
-
self.frames_export()
|
148
|
-
|
149
|
-
self.tmp_f.seek(0)
|
150
|
-
size = self.tmp_f.getbuffer().nbytes
|
151
|
-
if CodecInfo.is_anim(self.in_f):
|
152
|
-
size_max = self.size_max_vid
|
153
|
-
else:
|
154
|
-
size_max = self.size_max_img
|
155
|
-
|
156
|
-
if not size_max or size < size_max:
|
157
|
-
self.tmp_fs[step_current] = self.tmp_f.read()
|
158
|
-
|
159
|
-
for i in range(self.steps+1):
|
160
|
-
if self.tmp_fs[i] != None:
|
161
|
-
self.tmp_fs[min(i+2,self.steps+1):] = [None] * (self.steps+1 - min(i+2,self.steps+1))
|
162
|
-
break
|
163
|
-
|
164
|
-
if not size_max:
|
165
|
-
self.write_out(self.tmp_fs[step_current], step_current)
|
166
|
-
return True, self.in_f, self.out_f, size
|
167
|
-
|
168
|
-
if size < size_max:
|
169
|
-
if step_upper - step_lower > 1:
|
170
|
-
step_upper = step_current
|
171
|
-
step_current = int((step_lower + step_upper) / 2)
|
172
|
-
self.cb_msg.put(f'[<] Compressed {self.in_f_name} -> {self.out_f_name} but size {size} < limit {size_max}, recompressing')
|
173
|
-
else:
|
174
|
-
self.write_out(self.tmp_fs[step_current], step_current)
|
175
|
-
return True, self.in_f, self.out_f, size
|
176
|
-
else:
|
177
|
-
if step_upper - step_lower > 1:
|
178
|
-
step_lower = step_current
|
179
|
-
step_current = round((step_lower + step_upper) / 2)
|
180
|
-
self.cb_msg.put(f'[>] Compressed {self.in_f_name} -> {self.out_f_name} but size {size} > limit {size_max}, recompressing')
|
181
|
-
else:
|
182
|
-
if self.steps - step_current > 1:
|
183
|
-
self.write_out(self.tmp_fs[step_current + 1], step_current)
|
184
|
-
return True, self.in_f, self.out_f, size
|
185
|
-
else:
|
186
|
-
self.cb_msg.put(f'[F] Failed Compression {self.in_f_name} -> {self.out_f_name}, cannot get below limit {size_max} with lowest quality under current settings')
|
187
|
-
return False, self.in_f, self.out_f, size
|
188
|
-
|
189
|
-
def write_out(self, data: bytes, step_current: Optional[int] = None):
|
190
|
-
if os.path.splitext(self.out_f)[0] == 'none':
|
191
|
-
self.out_f = None
|
192
|
-
elif os.path.splitext(self.out_f)[0] == 'bytes':
|
193
|
-
self.out_f = data
|
194
|
-
else:
|
195
|
-
with open(self.out_f, 'wb+') as f:
|
196
|
-
f.write(data)
|
197
|
-
|
198
|
-
if step_current:
|
199
|
-
self.cb_msg.put(f'[S] Successful compression {self.in_f_name} -> {self.out_f_name} (step {step_current})')
|
200
|
-
|
201
|
-
def frames_import(self):
|
202
|
-
if self.in_f_ext in ('.tgs', '.lottie', '.json'):
|
203
|
-
self.frames_import_lottie()
|
204
|
-
else:
|
205
|
-
self.frames_import_imageio()
|
206
|
-
|
207
|
-
def frames_import_imageio(self):
|
208
|
-
if self.in_f_ext in '.webp':
|
209
|
-
# ffmpeg do not support webp decoding (yet)
|
210
|
-
for frame in iio.imiter(self.in_f, plugin='pillow', mode='RGBA'):
|
211
|
-
self.frames_raw.append(frame)
|
212
|
-
else:
|
213
|
-
frame_format = 'rgba'
|
214
|
-
# Crashes when handling some webm in yuv420p and convert to rgba
|
215
|
-
# https://github.com/PyAV-Org/PyAV/issues/1166
|
216
|
-
metadata = iio.immeta(self.in_f, plugin='pyav', exclude_applied=False)
|
217
|
-
context = None
|
218
|
-
if metadata.get('video_format') == 'yuv420p':
|
219
|
-
if metadata.get('alpha_mode') != '1':
|
220
|
-
frame_format = 'rgb24'
|
221
|
-
if metadata.get('codec') == 'vp8':
|
222
|
-
context = CodecContext.create('v8', 'r')
|
223
|
-
elif metadata.get('codec') == 'vp9':
|
224
|
-
context = CodecContext.create('libvpx-vp9', 'r')
|
225
|
-
|
226
|
-
with av.open(self.in_f) as container:
|
227
|
-
if not context:
|
228
|
-
context = container.streams.video[0].codec_context
|
229
|
-
for packet in container.demux(video=0):
|
230
|
-
for frame in context.decode(packet):
|
231
|
-
frame = frame.to_ndarray(format=frame_format)
|
232
|
-
if frame_format == 'rgb24':
|
233
|
-
frame = np.dstack((frame, np.zeros(frame.shape[:2], dtype=np.uint8)+255))
|
234
|
-
self.frames_raw.append(frame)
|
235
|
-
|
236
|
-
def frames_import_lottie(self):
|
237
|
-
if self.in_f_ext == '.tgs':
|
238
|
-
anim = LottieAnimation.from_tgs(self.in_f)
|
239
|
-
else:
|
240
|
-
if type(self.in_f) == str:
|
241
|
-
anim = LottieAnimation.from_file(self.in_f)
|
242
|
-
else:
|
243
|
-
anim = LottieAnimation.from_data(self.in_f.read().decode('utf-8'))
|
244
|
-
|
245
|
-
for i in range(anim.lottie_animation_get_totalframe()):
|
246
|
-
frame = np.asarray(anim.render_pillow_frame(frame_num=i))
|
247
|
-
self.frames_raw.append(frame)
|
248
|
-
|
249
|
-
anim.lottie_animation_destroy()
|
250
|
-
|
251
|
-
def frames_resize(self, frames_in: list[np.ndarray]) -> list[np.ndarray]:
|
252
|
-
frames_out = []
|
253
|
-
|
254
|
-
for frame in frames_in:
|
255
|
-
im = Image.fromarray(frame, 'RGBA')
|
256
|
-
width, height = im.size
|
257
|
-
|
258
|
-
if self.res_w == None:
|
259
|
-
self.res_w = width
|
260
|
-
if self.res_h == None:
|
261
|
-
self.res_h = height
|
262
|
-
|
263
|
-
if width > height:
|
264
|
-
width_new = self.res_w
|
265
|
-
height_new = height * self.res_w // width
|
266
|
-
else:
|
267
|
-
height_new = self.res_h
|
268
|
-
width_new = width * self.res_h // height
|
269
|
-
im = im.resize((width_new, height_new), resample=Image.LANCZOS)
|
270
|
-
im_new = Image.new('RGBA', (self.res_w, self.res_h), (0, 0, 0, 0))
|
271
|
-
im_new.paste(im, ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2))
|
272
|
-
frames_out.append(np.asarray(im_new))
|
273
|
-
|
274
|
-
return frames_out
|
275
|
-
|
276
|
-
def frames_drop(self, frames_in: list[np.ndarray]) -> list[np.ndarray]:
|
277
|
-
if not self.fps:
|
278
|
-
return [frames_in[0]]
|
279
|
-
|
280
|
-
frames_out = []
|
281
|
-
|
282
|
-
frames_orig = CodecInfo.get_file_frames(self.in_f)
|
283
|
-
fps_orig = CodecInfo.get_file_fps(self.in_f)
|
284
|
-
duration_orig = frames_orig / fps_orig * 1000
|
285
|
-
|
286
|
-
# fps_ratio: 1 frame in new anim equal to how many frame in old anim
|
287
|
-
# speed_ratio: How much to speed up / slow down
|
288
|
-
fps_ratio = fps_orig / self.fps
|
289
|
-
if self.duration_min and self.duration_min > 0 and duration_orig < self.duration_min:
|
290
|
-
speed_ratio = duration_orig / self.duration_min
|
291
|
-
elif self.duration_max and self.duration_max > 0 and duration_orig > self.duration_max:
|
292
|
-
speed_ratio = duration_orig / self.duration_max
|
293
|
-
else:
|
294
|
-
speed_ratio = 1
|
295
|
-
|
296
|
-
frame_current = 0
|
297
|
-
frame_current_float = 0
|
298
|
-
while frame_current < len(frames_in):
|
299
|
-
frames_out.append(frames_in[frame_current])
|
300
|
-
frame_current_float += fps_ratio * speed_ratio
|
301
|
-
frame_current = round(frame_current_float)
|
302
|
-
|
303
|
-
return frames_out
|
304
|
-
|
305
|
-
def frames_export(self):
|
306
|
-
if self.out_f_ext in ('.apng', '.png') and self.fps:
|
307
|
-
self.frames_export_apng()
|
308
|
-
elif self.out_f_ext == '.png':
|
309
|
-
self.frames_export_png()
|
310
|
-
elif self.out_f_ext == '.webp' and self.fps:
|
311
|
-
self.frames_export_webp()
|
312
|
-
elif self.fps:
|
313
|
-
self.frames_export_pyav()
|
314
|
-
else:
|
315
|
-
self.frames_export_pil()
|
316
|
-
|
317
|
-
def frames_export_pil(self):
|
318
|
-
image = Image.fromarray(self.frames_processed[0])
|
319
|
-
image.save(
|
320
|
-
self.tmp_f,
|
321
|
-
format=self.out_f_ext.replace('.', ''),
|
322
|
-
quality=self.quality
|
323
|
-
)
|
324
|
-
|
325
|
-
def frames_export_pyav(self):
|
326
|
-
options = {}
|
327
|
-
|
328
|
-
if type(self.quality) == int:
|
329
|
-
options['quality'] = str(self.quality)
|
330
|
-
options['lossless'] = '0'
|
331
|
-
|
332
|
-
if self.out_f_ext == '.gif':
|
333
|
-
codec = 'gif'
|
334
|
-
pixel_format = 'rgb8'
|
335
|
-
options['loop'] = '0'
|
336
|
-
elif self.out_f_ext in ('.apng', '.png'):
|
337
|
-
codec = 'apng'
|
338
|
-
pixel_format = 'rgba'
|
339
|
-
options['plays'] = '0'
|
340
|
-
else:
|
341
|
-
codec = 'vp9'
|
342
|
-
pixel_format = 'yuva420p'
|
343
|
-
options['loop'] = '0'
|
344
|
-
|
345
|
-
with av.open(self.tmp_f, 'w', format=self.out_f_ext.replace('.', '')) as output:
|
346
|
-
out_stream = output.add_stream(codec, rate=self.fps, options=options)
|
347
|
-
out_stream.width = self.res_w
|
348
|
-
out_stream.height = self.res_h
|
349
|
-
out_stream.pix_fmt = pixel_format
|
350
|
-
|
351
|
-
for frame in self.frames_processed:
|
352
|
-
av_frame = av.VideoFrame.from_ndarray(frame, format='rgba')
|
353
|
-
for packet in out_stream.encode(av_frame):
|
354
|
-
output.mux(packet)
|
355
|
-
|
356
|
-
for packet in out_stream.encode():
|
357
|
-
output.mux(packet)
|
358
|
-
|
359
|
-
def frames_export_webp(self):
|
360
|
-
config = webp.WebPConfig.new(quality=self.quality)
|
361
|
-
enc = webp.WebPAnimEncoder.new(self.res_w, self.res_h)
|
362
|
-
timestamp_ms = 0
|
363
|
-
for frame in self.frames_processed:
|
364
|
-
pic = webp.WebPPicture.from_numpy(frame)
|
365
|
-
enc.encode_frame(pic, timestamp_ms, config=config)
|
366
|
-
timestamp_ms += int(1 / self.fps * 1000)
|
367
|
-
anim_data = enc.assemble(timestamp_ms)
|
368
|
-
self.tmp_f.write(anim_data.buffer())
|
369
|
-
|
370
|
-
def frames_export_png(self):
|
371
|
-
image = Image.fromarray(self.frames_processed[0], 'RGBA')
|
372
|
-
if self.color and self.color < 256:
|
373
|
-
image_quant = image.quantize(colors=self.color, method=2)
|
374
|
-
else:
|
375
|
-
image_quant = image
|
376
|
-
with io.BytesIO() as f:
|
377
|
-
image_quant.save(f, format='png')
|
378
|
-
f.seek(0)
|
379
|
-
frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
|
380
|
-
self.tmp_f.write(frame_optimized)
|
381
|
-
|
382
|
-
def frames_export_apng(self):
|
383
|
-
frames_concat = np.concatenate(self.frames_processed)
|
384
|
-
image_concat = Image.fromarray(frames_concat, 'RGBA')
|
385
|
-
if self.color and self.color < 256:
|
386
|
-
image_quant = image_concat.quantize(colors=self.color, method=2)
|
387
|
-
else:
|
388
|
-
image_quant = image_concat
|
389
|
-
|
390
|
-
for i in range(0, image_quant.height, self.res_h):
|
391
|
-
with io.BytesIO() as f:
|
392
|
-
image_quant.crop((0, i, image_quant.width, i+self.res_h)).save(f, format='png')
|
393
|
-
f.seek(0)
|
394
|
-
frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
|
395
|
-
image_final = Image.open(io.BytesIO(frame_optimized)).convert('RGBA')
|
396
|
-
frame_final = create_frame_from_rgba(np.array(image_final), image_final.width, image_final.height)
|
397
|
-
frame_final.delay_num = int(1000 / self.fps)
|
398
|
-
frame_final.delay_den = 1000
|
399
|
-
self.apngasm.add_frame(frame_final)
|
400
|
-
|
401
|
-
with CacheStore.get_cache_store(path=self.cache_dir) as tempdir:
|
402
|
-
self.apngasm.assemble(os.path.join(tempdir, f'out{self.out_f_ext}'))
|
403
|
-
|
404
|
-
with open(os.path.join(tempdir, f'out{self.out_f_ext}'), 'rb') as f:
|
405
|
-
self.tmp_f.write(f.read())
|
406
|
-
|
407
|
-
self.apngasm.reset()
|
@@ -1,70 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
import os
|
3
|
-
import sys
|
4
|
-
import platform
|
5
|
-
|
6
|
-
class CurrDir:
|
7
|
-
@staticmethod
|
8
|
-
def get_curr_dir():
|
9
|
-
appimage_path = os.getenv('APPIMAGE')
|
10
|
-
|
11
|
-
cwd = os.getcwd()
|
12
|
-
home_dir = os.path.expanduser('~')
|
13
|
-
if os.path.isdir(os.path.join(home_dir, 'Desktop')):
|
14
|
-
fallback_dir = os.path.join(home_dir, 'Desktop')
|
15
|
-
else:
|
16
|
-
fallback_dir = home_dir
|
17
|
-
|
18
|
-
if platform.system() == 'Darwin' and getattr(sys, 'frozen', False) and '.app/Contents/MacOS' in cwd:
|
19
|
-
if cwd.startswith('/Applications/'):
|
20
|
-
curr_dir = fallback_dir
|
21
|
-
else:
|
22
|
-
curr_dir = os.path.abspath('../../../')
|
23
|
-
elif appimage_path:
|
24
|
-
curr_dir = os.path.split(appimage_path)[0]
|
25
|
-
elif (cwd.startswith('/usr/bin/')
|
26
|
-
or cwd.startswith('/bin')
|
27
|
-
or cwd.startswith('/usr/local/bin')
|
28
|
-
or cwd.startswith('C:\\Program Files')
|
29
|
-
or 'site-packages' in __file__):
|
30
|
-
curr_dir = fallback_dir
|
31
|
-
else:
|
32
|
-
curr_dir = cwd
|
33
|
-
|
34
|
-
if os.access(curr_dir, os.W_OK):
|
35
|
-
return curr_dir
|
36
|
-
else:
|
37
|
-
return fallback_dir
|
38
|
-
|
39
|
-
@staticmethod
|
40
|
-
def get_config_dir():
|
41
|
-
appimage_path = os.getenv('APPIMAGE')
|
42
|
-
|
43
|
-
cwd = os.getcwd()
|
44
|
-
if platform.system() == 'Windows':
|
45
|
-
fallback_dir = os.path.expandvars('%APPDATA%\\sticker-convert')
|
46
|
-
else:
|
47
|
-
fallback_dir = os.path.expanduser('~/.config/sticker-convert')
|
48
|
-
|
49
|
-
if platform.system() == 'Darwin' and getattr(sys, 'frozen', False) and '.app/Contents/MacOS' in cwd:
|
50
|
-
if cwd.startswith('/Applications/'):
|
51
|
-
config_dir = fallback_dir
|
52
|
-
else:
|
53
|
-
config_dir = os.path.abspath('../../../')
|
54
|
-
elif appimage_path:
|
55
|
-
config_dir = os.path.split(appimage_path)[0]
|
56
|
-
elif (cwd.startswith('/usr/bin/')
|
57
|
-
or cwd.startswith('/bin')
|
58
|
-
or cwd.startswith('/usr/local/bin')
|
59
|
-
or cwd.startswith('C:\\Program Files')
|
60
|
-
or 'site-packages' in __file__):
|
61
|
-
config_dir = fallback_dir
|
62
|
-
else:
|
63
|
-
config_dir = cwd
|
64
|
-
|
65
|
-
os.makedirs(config_dir, exist_ok=True)
|
66
|
-
if os.access(config_dir, os.W_OK):
|
67
|
-
return config_dir
|
68
|
-
else:
|
69
|
-
os.makedirs(fallback_dir, exist_ok=True)
|
70
|
-
return fallback_dir
|
@@ -1,188 +0,0 @@
|
|
1
|
-
#!/usr/bin/env python3
|
2
|
-
import os
|
3
|
-
import unicodedata
|
4
|
-
import re
|
5
|
-
from typing import Optional, Union
|
6
|
-
|
7
|
-
from .codec_info import CodecInfo # type: ignore
|
8
|
-
|
9
|
-
'''
|
10
|
-
Example of spec
|
11
|
-
spec = {
|
12
|
-
"res": {
|
13
|
-
"w": {
|
14
|
-
"min": 256,
|
15
|
-
"max": 512
|
16
|
-
},
|
17
|
-
"h": {
|
18
|
-
"min": 256,
|
19
|
-
"max": 512
|
20
|
-
}
|
21
|
-
},
|
22
|
-
"fps": {
|
23
|
-
"min": 1,
|
24
|
-
"max": 30
|
25
|
-
},
|
26
|
-
"size_max": {
|
27
|
-
"vid": 500000,
|
28
|
-
"img": 300000
|
29
|
-
},
|
30
|
-
"duration": {
|
31
|
-
"min": 0,
|
32
|
-
"max": 3
|
33
|
-
},
|
34
|
-
"animated": True,
|
35
|
-
"square": True,
|
36
|
-
"format": ".apng"
|
37
|
-
}
|
38
|
-
'''
|
39
|
-
|
40
|
-
class FormatVerify:
|
41
|
-
@staticmethod
|
42
|
-
def check_file(file: str, spec: dict) -> bool:
|
43
|
-
return (
|
44
|
-
FormatVerify.check_presence(file) and
|
45
|
-
FormatVerify.check_file_res(file, res=spec.get('res'), square=spec.get('square')) and
|
46
|
-
FormatVerify.check_file_fps(file, fps=spec.get('fps')) and
|
47
|
-
FormatVerify.check_file_size(file, size=spec.get('size_max')) and
|
48
|
-
FormatVerify.check_animated(file, animated=spec.get('animated')) and
|
49
|
-
FormatVerify.check_format(file, format=spec.get('format')) and
|
50
|
-
FormatVerify.check_duration(file, duration=spec.get('duration'))
|
51
|
-
)
|
52
|
-
|
53
|
-
@staticmethod
|
54
|
-
def check_presence(file: str) -> bool:
|
55
|
-
return os.path.isfile(file)
|
56
|
-
|
57
|
-
@staticmethod
|
58
|
-
def check_file_res(file: str, res: Optional[dict[dict, int]] = None, square: Optional[bool] = None) -> bool:
|
59
|
-
file_width, file_height = CodecInfo.get_file_res(file)
|
60
|
-
|
61
|
-
if res and (res.get('w', {}).get('min') and res.get('w', {}).get('max')) and (file_width < res['w']['min'] or file_width > res['w']['max']): # type: ignore[call-overload,index]
|
62
|
-
return False
|
63
|
-
if res and (res.get('h', {}).get('min') and res.get('h', {}).get('max')) and (file_height < res['h']['min'] or file_height > res['h']['max']): # type: ignore[call-overload,index]
|
64
|
-
return False
|
65
|
-
if square != None and file_height != file_width:
|
66
|
-
return False
|
67
|
-
|
68
|
-
return True
|
69
|
-
|
70
|
-
@staticmethod
|
71
|
-
def check_file_fps(file: str, fps: Optional[dict[str, float]]) -> bool:
|
72
|
-
file_fps = CodecInfo.get_file_fps(file)
|
73
|
-
|
74
|
-
if fps and fps.get('min') != None and file_fps < fps['min']:
|
75
|
-
return False
|
76
|
-
if fps and fps.get('max') != None and file_fps > fps['max']:
|
77
|
-
return False
|
78
|
-
|
79
|
-
return True
|
80
|
-
|
81
|
-
@staticmethod
|
82
|
-
def check_file_size(file: str, size: Optional[dict[str, int]] = None) -> bool:
|
83
|
-
file_size = os.path.getsize(file)
|
84
|
-
file_animated = CodecInfo.is_anim(file)
|
85
|
-
|
86
|
-
if file_animated == True and size and size.get('vid') != None and file_size > size['vid']:
|
87
|
-
return False
|
88
|
-
if file_animated == False and size and size.get('img') != None and file_size > size['img']:
|
89
|
-
return False
|
90
|
-
|
91
|
-
return True
|
92
|
-
|
93
|
-
@staticmethod
|
94
|
-
def check_animated(file: str, animated: Optional[bool] = None) -> bool:
|
95
|
-
if animated != None and CodecInfo.is_anim(file) != animated:
|
96
|
-
return False
|
97
|
-
|
98
|
-
return True
|
99
|
-
|
100
|
-
@staticmethod
|
101
|
-
def check_format(file: str, format: Union[dict[str, str], str, tuple, list, None] = None, allow_compat_ext: bool = True):
|
102
|
-
compat_ext = {
|
103
|
-
'.jpg': '.jpeg',
|
104
|
-
'.jpeg': '.jpg',
|
105
|
-
'.png': '.apng',
|
106
|
-
'.apng': '.png'
|
107
|
-
}
|
108
|
-
|
109
|
-
formats = []
|
110
|
-
if format != None:
|
111
|
-
if type(format) == dict:
|
112
|
-
if FormatVerify.check_animated(file):
|
113
|
-
format = format.get('vid')
|
114
|
-
else:
|
115
|
-
format = format.get('img')
|
116
|
-
|
117
|
-
if type(format) == str:
|
118
|
-
formats.append(format)
|
119
|
-
elif (type(format) == tuple or type(format) == list):
|
120
|
-
formats.extend(format)
|
121
|
-
|
122
|
-
if allow_compat_ext:
|
123
|
-
for fmt in formats.copy():
|
124
|
-
if fmt in compat_ext:
|
125
|
-
formats.append(compat_ext.get(fmt)) # type: ignore[arg-type]
|
126
|
-
if CodecInfo.get_file_ext(file) not in formats:
|
127
|
-
return False
|
128
|
-
|
129
|
-
return True
|
130
|
-
|
131
|
-
@staticmethod
|
132
|
-
def check_duration(file: str, duration: Optional[dict[str, int]] = None) -> bool:
|
133
|
-
file_duration = CodecInfo.get_file_duration(file)
|
134
|
-
if duration and duration.get('min') != None and file_duration < duration['min']:
|
135
|
-
return False
|
136
|
-
if duration and duration.get('max') != None and file_duration > duration['max']:
|
137
|
-
return False
|
138
|
-
|
139
|
-
return True
|
140
|
-
|
141
|
-
@staticmethod
|
142
|
-
def sanitize_filename(filename: str) -> str:
|
143
|
-
# Based on https://gitlab.com/jplusplus/sanitize-filename/-/blob/master/sanitize_filename/sanitize_filename.py
|
144
|
-
# Replace illegal character with '_'
|
145
|
-
"""Return a fairly safe version of the filename.
|
146
|
-
|
147
|
-
We don't limit ourselves to ascii, because we want to keep municipality
|
148
|
-
names, etc, but we do want to get rid of anything potentially harmful,
|
149
|
-
and make sure we do not exceed Windows filename length limits.
|
150
|
-
Hence a less safe blacklist, rather than a whitelist.
|
151
|
-
"""
|
152
|
-
blacklist = ["\\", "/", ":", "*", "?", "\"", "<", ">", "|", "\0"]
|
153
|
-
reserved = [
|
154
|
-
"CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5",
|
155
|
-
"COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5",
|
156
|
-
"LPT6", "LPT7", "LPT8", "LPT9",
|
157
|
-
] # Reserved words on Windows
|
158
|
-
filename = "".join(c if c not in blacklist else '_' for c in filename)
|
159
|
-
# Remove all charcters below code point 32
|
160
|
-
filename = "".join(c if 31 < ord(c) else '_' for c in filename)
|
161
|
-
filename = unicodedata.normalize("NFKD", filename)
|
162
|
-
filename = filename.rstrip(". ") # Windows does not allow these at end
|
163
|
-
filename = filename.strip()
|
164
|
-
if all([x == "." for x in filename]):
|
165
|
-
filename = "__" + filename
|
166
|
-
if filename in reserved:
|
167
|
-
filename = "__" + filename
|
168
|
-
if len(filename) == 0:
|
169
|
-
filename = "__"
|
170
|
-
if len(filename) > 255:
|
171
|
-
parts = re.split(r"/|\\", filename)[-1].split(".")
|
172
|
-
if len(parts) > 1:
|
173
|
-
ext = "." + parts.pop()
|
174
|
-
filename = filename[:-len(ext)]
|
175
|
-
else:
|
176
|
-
ext = ""
|
177
|
-
if filename == "":
|
178
|
-
filename = "__"
|
179
|
-
if len(ext) > 254:
|
180
|
-
ext = ext[254:]
|
181
|
-
maxl = 255 - len(ext)
|
182
|
-
filename = filename[:maxl]
|
183
|
-
filename = filename + ext
|
184
|
-
# Re-check last character (if there was no extension)
|
185
|
-
filename = filename.rstrip(". ")
|
186
|
-
if len(filename) == 0:
|
187
|
-
filename = "__"
|
188
|
-
return filename
|