sticker-convert 2.1.5__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 +42 -32
- 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 +30 -12
- sticker_convert/downloaders/download_signal.py +48 -32
- sticker_convert/downloaders/download_telegram.py +71 -26
- sticker_convert/gui.py +79 -130
- 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 +6 -5
- 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.5.dist-info → sticker_convert-2.1.7.dist-info}/METADATA +11 -11
- {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/RECORD +52 -50
- {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/WHEEL +1 -1
- sticker_convert/utils/converter.py +0 -399
- 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.5.dist-info → sticker_convert-2.1.7.dist-info}/LICENSE +0 -0
- {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/entry_points.txt +0 -0
- {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/top_level.txt +0 -0
sticker_convert/__init__.py
CHANGED
sticker_convert/__main__.py
CHANGED
@@ -10,12 +10,15 @@ def main():
|
|
10
10
|
script_path = os.path.dirname(sys.argv[0])
|
11
11
|
os.chdir(script_path)
|
12
12
|
if len(sys.argv) == 1:
|
13
|
-
print(
|
14
|
-
from sticker_convert.gui import GUI
|
13
|
+
print("Launching GUI...")
|
14
|
+
from sticker_convert.gui import GUI # type: ignore
|
15
|
+
|
15
16
|
GUI().gui()
|
16
17
|
else:
|
17
18
|
from sticker_convert.cli import CLI
|
19
|
+
|
18
20
|
CLI().cli()
|
19
21
|
|
20
|
-
|
21
|
-
|
22
|
+
|
23
|
+
if __name__ == "__main__":
|
24
|
+
main()
|
sticker_convert/cli.py
CHANGED
@@ -7,12 +7,13 @@ from typing import Optional
|
|
7
7
|
|
8
8
|
from tqdm import tqdm
|
9
9
|
|
10
|
-
from .
|
11
|
-
from .
|
12
|
-
from .
|
13
|
-
from .auth.
|
14
|
-
from .auth.
|
15
|
-
from .utils.
|
10
|
+
from .job import Job # type: ignore
|
11
|
+
from .job_option import InputOption, CompOption, OutputOption, CredOption # type: ignore
|
12
|
+
from .utils.files.json_manager import JsonManager # type: ignore
|
13
|
+
from .utils.auth.get_kakao_auth import GetKakaoAuth # type: ignore
|
14
|
+
from .utils.auth.get_signal_auth import GetSignalAuth # type: ignore
|
15
|
+
from .utils.auth.get_line_auth import GetLineAuth # type: ignore
|
16
|
+
from .utils.files.dir_utils import DirUtils # type: ignore
|
16
17
|
from .utils.url_detect import UrlDetect # type: ignore
|
17
18
|
from .__init__ import __version__ # type: ignore
|
18
19
|
|
@@ -92,23 +93,24 @@ class CLI:
|
|
92
93
|
|
93
94
|
self.no_confirm = args.no_confirm
|
94
95
|
|
95
|
-
self.get_opt_input(args)
|
96
|
-
self.get_opt_output(args)
|
97
|
-
self.get_opt_comp(args)
|
98
|
-
self.get_opt_cred(args)
|
96
|
+
self.opt_input = InputOption(self.get_opt_input(args))
|
97
|
+
self.opt_output = OutputOption(self.get_opt_output(args))
|
98
|
+
self.opt_comp = CompOption(self.get_opt_comp(args))
|
99
|
+
self.opt_cred = CredOption(self.get_opt_cred(args))
|
99
100
|
|
100
|
-
|
101
|
+
job = Job(
|
101
102
|
self.opt_input, self.opt_comp, self.opt_output, self.opt_cred,
|
102
|
-
self.input_presets, self.output_presets,
|
103
103
|
self.cb_msg, self.cb_msg_block, self.cb_bar, self.cb_ask_bool
|
104
104
|
)
|
105
105
|
|
106
|
-
|
106
|
+
status = job.start()
|
107
107
|
|
108
|
-
if
|
108
|
+
if status == 1:
|
109
109
|
self.cb_msg(msg='An error occured during this run.')
|
110
|
+
elif status == 2:
|
111
|
+
self.cb_msg(msg='Job cancelled.')
|
110
112
|
|
111
|
-
def get_opt_input(self, args):
|
113
|
+
def get_opt_input(self, args) -> dict:
|
112
114
|
download_options = {
|
113
115
|
'auto': args.download_auto,
|
114
116
|
'signal': args.download_signal,
|
@@ -132,13 +134,15 @@ class CLI:
|
|
132
134
|
self.cb_msg(f'Error: Unrecognied URL input source for url: {url}')
|
133
135
|
exit()
|
134
136
|
|
135
|
-
|
137
|
+
opt_input = {
|
136
138
|
'option': download_option,
|
137
139
|
'url': url,
|
138
|
-
'dir': args.input_dir if args.input_dir else os.path.join(
|
140
|
+
'dir': args.input_dir if args.input_dir else os.path.join(DirUtils.get_curr_dir(), 'stickers_input')
|
139
141
|
}
|
140
142
|
|
141
|
-
|
143
|
+
return opt_input
|
144
|
+
|
145
|
+
def get_opt_output(self, args) -> dict:
|
142
146
|
if args.export_whatsapp:
|
143
147
|
export_option = 'whatsapp'
|
144
148
|
elif args.export_signal:
|
@@ -150,14 +154,16 @@ class CLI:
|
|
150
154
|
else:
|
151
155
|
export_option = 'local'
|
152
156
|
|
153
|
-
|
157
|
+
opt_output = {
|
154
158
|
'option': export_option,
|
155
|
-
'dir': args.output_dir if args.output_dir else os.path.join(
|
159
|
+
'dir': args.output_dir if args.output_dir else os.path.join(DirUtils.get_curr_dir(), 'stickers_output'),
|
156
160
|
'title': args.title,
|
157
161
|
'author': args.author
|
158
162
|
}
|
159
163
|
|
160
|
-
|
164
|
+
return opt_output
|
165
|
+
|
166
|
+
def get_opt_comp(self, args) -> dict:
|
161
167
|
preset = args.preset
|
162
168
|
if args.preset == 'custom':
|
163
169
|
if sum((args.export_whatsapp, args.export_signal, args.export_telegram, args.export_imessage)) > 1:
|
@@ -172,7 +178,7 @@ class CLI:
|
|
172
178
|
elif args.export_imessage:
|
173
179
|
preset = 'imessage_small'
|
174
180
|
elif args.preset == 'auto':
|
175
|
-
output_option = self.opt_output
|
181
|
+
output_option = self.opt_output.option
|
176
182
|
if output_option == 'local':
|
177
183
|
preset = 'custom'
|
178
184
|
args.no_compress = True
|
@@ -184,7 +190,7 @@ class CLI:
|
|
184
190
|
preset = output_option
|
185
191
|
self.cb_msg(f'Auto compression option set to {preset}')
|
186
192
|
|
187
|
-
|
193
|
+
opt_comp = {
|
188
194
|
'preset': preset,
|
189
195
|
'size_max': {
|
190
196
|
'img': self.compression_presets[preset]['size_max']['img'] if args.img_size_max == None else args.img_size_max,
|
@@ -228,15 +234,17 @@ class CLI:
|
|
228
234
|
'processes': args.processes if args.processes else math.ceil(cpu_count() / 2)
|
229
235
|
}
|
230
236
|
|
231
|
-
|
232
|
-
|
237
|
+
return opt_comp
|
238
|
+
|
239
|
+
def get_opt_cred(self, args) -> dict:
|
240
|
+
creds_path = os.path.join(DirUtils.get_config_dir(), 'creds.json')
|
233
241
|
creds = JsonManager.load_json(creds_path)
|
234
242
|
if creds:
|
235
243
|
self.cb_msg('Loaded credentials from creds.json')
|
236
244
|
else:
|
237
245
|
creds = {}
|
238
246
|
|
239
|
-
|
247
|
+
opt_cred = {
|
240
248
|
'signal': {
|
241
249
|
'uuid': args.signal_uuid if args.signal_uuid else creds.get('signal', {}).get('uuid'),
|
242
250
|
'password': args.signal_password if args.signal_password else creds.get('signal', {}).get('password')
|
@@ -262,7 +270,7 @@ class CLI:
|
|
262
270
|
auth_token = m.get_cred()
|
263
271
|
|
264
272
|
if auth_token:
|
265
|
-
|
273
|
+
opt_cred['kakao']['auth_token'] = auth_token
|
266
274
|
|
267
275
|
self.cb_msg(f'Got auth_token successfully: {auth_token}')
|
268
276
|
|
@@ -274,8 +282,8 @@ class CLI:
|
|
274
282
|
uuid, password = m.get_cred()
|
275
283
|
|
276
284
|
if uuid and password:
|
277
|
-
|
278
|
-
|
285
|
+
opt_cred['signal']['uuid'] = uuid
|
286
|
+
opt_cred['signal']['password'] = password
|
279
287
|
|
280
288
|
self.cb_msg(f'Got uuid and password successfully: {uuid}, {password}')
|
281
289
|
break
|
@@ -286,16 +294,18 @@ class CLI:
|
|
286
294
|
line_cookies = m.get_cred()
|
287
295
|
|
288
296
|
if line_cookies:
|
289
|
-
|
297
|
+
opt_cred['line']['cookies'] = line_cookies
|
290
298
|
|
291
299
|
self.cb_msg('Got Line cookies successfully')
|
292
300
|
else:
|
293
301
|
self.cb_msg('Failed to get Line cookies. Have you logged in the web browser?')
|
294
302
|
|
295
303
|
if args.save_cred:
|
296
|
-
creds_path = os.path.join(
|
297
|
-
JsonManager.save_json(creds_path,
|
304
|
+
creds_path = os.path.join(DirUtils.get_config_dir(), 'creds.json')
|
305
|
+
JsonManager.save_json(creds_path, opt_cred)
|
298
306
|
self.cb_msg('Saved credentials to creds.json')
|
307
|
+
|
308
|
+
return opt_cred
|
299
309
|
|
300
310
|
def cb_ask_str(self, msg: Optional[str] = None, initialvalue: Optional[str] = None, cli_show_initialvalue: bool = True) -> str:
|
301
311
|
self.cb_msg(msg)
|
@@ -0,0 +1,432 @@
|
|
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 .utils.media.codec_info import CodecInfo # type: ignore
|
18
|
+
from .utils.files.cache_store import CacheStore # type: ignore
|
19
|
+
from .utils.media.format_verify import FormatVerify # type: ignore
|
20
|
+
from .utils.fake_cb_msg import FakeCbMsg # type: ignore
|
21
|
+
from .job_option import CompOption
|
22
|
+
|
23
|
+
def get_step_value(
|
24
|
+
max: Optional[int], min: Optional[int],
|
25
|
+
step: int, steps: int
|
26
|
+
) -> Optional[int]:
|
27
|
+
|
28
|
+
if max and min:
|
29
|
+
return round((max - min) * step / steps + min)
|
30
|
+
else:
|
31
|
+
return None
|
32
|
+
|
33
|
+
class StickerConvert:
|
34
|
+
MSG_START_COMP = '[I] Start compressing {} -> {}'
|
35
|
+
MSG_SKIP_COMP = '[S] Compatible file found, skip compress and just copy {} -> {}'
|
36
|
+
MSG_COMP = ('[C] Compressing {} -> {} res={}x{}, '
|
37
|
+
'quality={}, fps={}, color={} (step {}-{}-{})')
|
38
|
+
MSG_REDO_COMP = '[{}] Compressed {} -> {} but size {} {} limit {}, recompressing'
|
39
|
+
MSG_DONE_COMP = '[S] Successful compression {} -> {} size {} (step {})'
|
40
|
+
MSG_FAIL_COMP = ('[F] Failed Compression {} -> {}, '
|
41
|
+
'cannot get below limit {} with lowest quality under current settings')
|
42
|
+
|
43
|
+
def __init__(self,
|
44
|
+
in_f: Union[str, list[str, io.BytesIO]],
|
45
|
+
out_f: str,
|
46
|
+
opt_comp: CompOption,
|
47
|
+
cb_msg: Union[FakeCbMsg, bool] = True):
|
48
|
+
|
49
|
+
if not isinstance(cb_msg, QueueType):
|
50
|
+
if cb_msg == False:
|
51
|
+
silent = True
|
52
|
+
else:
|
53
|
+
silent = False
|
54
|
+
cb_msg = FakeCbMsg(print, silent=silent)
|
55
|
+
|
56
|
+
if isinstance(in_f, str):
|
57
|
+
self.in_f = in_f
|
58
|
+
self.in_f_name = os.path.split(self.in_f)[1]
|
59
|
+
self.in_f_ext = CodecInfo.get_file_ext(self.in_f)
|
60
|
+
else:
|
61
|
+
self.in_f = in_f[1]
|
62
|
+
self.in_f_name = os.path.split(in_f[0])[1]
|
63
|
+
self.in_f_ext = CodecInfo.get_file_ext(in_f[0])
|
64
|
+
|
65
|
+
self.out_f = out_f
|
66
|
+
self.out_f_name = os.path.split(self.out_f)[1]
|
67
|
+
self.out_f_ext = os.path.splitext(out_f)[1]
|
68
|
+
|
69
|
+
self.cb_msg = cb_msg
|
70
|
+
self.frames_raw: list[np.ndarray] = []
|
71
|
+
self.frames_processed: list[np.ndarray] = []
|
72
|
+
self.opt_comp = opt_comp
|
73
|
+
if not self.opt_comp.steps:
|
74
|
+
self.opt_comp.steps = 1
|
75
|
+
|
76
|
+
self.size = 0
|
77
|
+
self.size_max = None
|
78
|
+
self.res_w = None
|
79
|
+
self.res_h = None
|
80
|
+
self.quality = None
|
81
|
+
self.fps = None
|
82
|
+
self.color = None
|
83
|
+
|
84
|
+
self.frames_orig = CodecInfo.get_file_frames(self.in_f)
|
85
|
+
self.fps_orig = CodecInfo.get_file_fps(self.in_f)
|
86
|
+
self.duration_orig = self.frames_orig / self.fps_orig * 1000
|
87
|
+
|
88
|
+
self.tmp_f = None
|
89
|
+
self.result = None
|
90
|
+
self.result_size = 0
|
91
|
+
self.result_step = None
|
92
|
+
|
93
|
+
self.apngasm = APNGAsm() # type: ignore[call-arg]
|
94
|
+
|
95
|
+
def convert(self) -> tuple[bool, str, Union[None, bytes, str], int]:
|
96
|
+
if (FormatVerify.check_format(self.in_f, fmt=self.out_f_ext) and
|
97
|
+
FormatVerify.check_file_res(self.in_f, res=self.opt_comp.res) and
|
98
|
+
FormatVerify.check_file_fps(self.in_f, fps=self.opt_comp.fps) and
|
99
|
+
FormatVerify.check_file_size(self.in_f, size=self.opt_comp.size_max) and
|
100
|
+
FormatVerify.check_duration(self.in_f, duration=self.opt_comp.duration)):
|
101
|
+
self.cb_msg.put(self.MSG_SKIP_COMP.format(self.in_f_name, self.out_f_name))
|
102
|
+
|
103
|
+
with open(self.in_f, 'rb') as f:
|
104
|
+
self.result = f.read()
|
105
|
+
self.result_size = os.path.getsize(self.in_f)
|
106
|
+
|
107
|
+
return self.compress_done(self.result)
|
108
|
+
|
109
|
+
self.cb_msg.put(self.MSG_START_COMP.format(self.in_f_name, self.out_f_name))
|
110
|
+
|
111
|
+
steps_list = []
|
112
|
+
for step in range(self.opt_comp.steps, -1, -1):
|
113
|
+
steps_list.append((
|
114
|
+
get_step_value(self.opt_comp.res_w_max, self.opt_comp.res_w_min, step, self.opt_comp.steps),
|
115
|
+
get_step_value(self.opt_comp.res_h_max, self.opt_comp.res_h_min, step, self.opt_comp.steps),
|
116
|
+
get_step_value(self.opt_comp.quality_max, self.opt_comp.quality_min, step, self.opt_comp.steps),
|
117
|
+
get_step_value(self.opt_comp.fps_max, self.opt_comp.fps_min, step, self.opt_comp.steps),
|
118
|
+
get_step_value(self.opt_comp.color_max, self.opt_comp.color_min, step, self.opt_comp.steps)
|
119
|
+
))
|
120
|
+
|
121
|
+
step_lower = 0
|
122
|
+
step_upper = self.opt_comp.steps
|
123
|
+
|
124
|
+
if self.opt_comp.size_max == [None, None]:
|
125
|
+
# No limit to size, create the best quality result
|
126
|
+
step_current = 0
|
127
|
+
else:
|
128
|
+
step_current = round((step_lower + step_upper) / 2)
|
129
|
+
|
130
|
+
self.frames_import()
|
131
|
+
while True:
|
132
|
+
param = steps_list[step_current]
|
133
|
+
self.res_w = param[0]
|
134
|
+
self.res_h = param[1]
|
135
|
+
self.quality = param[2]
|
136
|
+
self.fps = param[3]
|
137
|
+
self.color = param[4]
|
138
|
+
|
139
|
+
self.tmp_f = io.BytesIO()
|
140
|
+
msg = self.MSG_COMP.format(
|
141
|
+
self.in_f_name, self.out_f_name,
|
142
|
+
self.res_w, self.res_h,
|
143
|
+
self.quality, self.fps, self.color,
|
144
|
+
step_lower, step_current, step_upper
|
145
|
+
)
|
146
|
+
self.cb_msg.put(msg)
|
147
|
+
|
148
|
+
self.frames_processed = self.frames_drop(self.frames_raw)
|
149
|
+
self.frames_processed = self.frames_resize(self.frames_processed)
|
150
|
+
self.frames_export()
|
151
|
+
|
152
|
+
self.tmp_f.seek(0)
|
153
|
+
self.size = self.tmp_f.getbuffer().nbytes
|
154
|
+
if CodecInfo.is_anim(self.in_f):
|
155
|
+
self.size_max = self.opt_comp.size_max_vid
|
156
|
+
else:
|
157
|
+
self.size_max = self.opt_comp.size_max_img
|
158
|
+
|
159
|
+
if (not self.size_max or
|
160
|
+
(self.size <= self.size_max and self.size >= self.result_size)):
|
161
|
+
self.result = self.tmp_f.read()
|
162
|
+
self.result_size = self.size
|
163
|
+
self.result_step = step_current
|
164
|
+
|
165
|
+
if step_upper - step_lower > 1:
|
166
|
+
if self.size <= self.size_max:
|
167
|
+
sign = '<'
|
168
|
+
step_upper = step_current
|
169
|
+
else:
|
170
|
+
sign = '>'
|
171
|
+
step_lower = step_current
|
172
|
+
step_current = int((step_lower + step_upper) / 2)
|
173
|
+
self.recompress(sign)
|
174
|
+
elif self.result or not self.size_max:
|
175
|
+
return self.compress_done(self.result, self.result_step)
|
176
|
+
else:
|
177
|
+
return self.compress_fail()
|
178
|
+
|
179
|
+
def recompress(self, sign: str):
|
180
|
+
msg = self.MSG_REDO_COMP.format(
|
181
|
+
sign, self.in_f_name, self.out_f_name, self.size, sign, self.size_max
|
182
|
+
)
|
183
|
+
self.cb_msg.put(msg)
|
184
|
+
|
185
|
+
def compress_fail(self) -> tuple[bool, str, Union[None, bytes, str], int]:
|
186
|
+
msg = self.MSG_FAIL_COMP.format(
|
187
|
+
self.in_f_name, self.out_f_name, self.size_max
|
188
|
+
)
|
189
|
+
self.cb_msg.put(msg)
|
190
|
+
|
191
|
+
return False, self.in_f, self.out_f, self.size
|
192
|
+
|
193
|
+
def compress_done(self,
|
194
|
+
data: bytes,
|
195
|
+
result_step: Optional[int] = None
|
196
|
+
) -> tuple[bool, str, Union[None, bytes, str], int]:
|
197
|
+
|
198
|
+
if os.path.splitext(self.out_f_name)[0] == 'none':
|
199
|
+
self.out_f = None
|
200
|
+
elif os.path.splitext(self.out_f_name)[0] == 'bytes':
|
201
|
+
self.out_f = data
|
202
|
+
else:
|
203
|
+
with open(self.out_f, 'wb+') as f:
|
204
|
+
f.write(data)
|
205
|
+
|
206
|
+
if result_step:
|
207
|
+
msg = self.MSG_DONE_COMP.format(
|
208
|
+
self.in_f_name, self.out_f_name, self.result_size, result_step
|
209
|
+
)
|
210
|
+
self.cb_msg.put(msg)
|
211
|
+
|
212
|
+
return True, self.in_f, self.out_f, self.result_size
|
213
|
+
|
214
|
+
def frames_import(self):
|
215
|
+
if self.in_f_ext in ('.tgs', '.lottie', '.json'):
|
216
|
+
self.frames_import_lottie()
|
217
|
+
else:
|
218
|
+
self.frames_import_imageio()
|
219
|
+
|
220
|
+
def frames_import_imageio(self):
|
221
|
+
if self.in_f_ext in '.webp':
|
222
|
+
# ffmpeg do not support webp decoding (yet)
|
223
|
+
for frame in iio.imiter(self.in_f, plugin='pillow', mode='RGBA'):
|
224
|
+
self.frames_raw.append(frame)
|
225
|
+
return
|
226
|
+
|
227
|
+
frame_format = 'rgba'
|
228
|
+
# Crashes when handling some webm in yuv420p and convert to rgba
|
229
|
+
# https://github.com/PyAV-Org/PyAV/issues/1166
|
230
|
+
metadata = iio.immeta(self.in_f, plugin='pyav', exclude_applied=False)
|
231
|
+
context = None
|
232
|
+
if metadata.get('video_format') == 'yuv420p':
|
233
|
+
if metadata.get('alpha_mode') != '1':
|
234
|
+
frame_format = 'rgb24'
|
235
|
+
if metadata.get('codec') == 'vp8':
|
236
|
+
context = CodecContext.create('v8', 'r')
|
237
|
+
elif metadata.get('codec') == 'vp9':
|
238
|
+
context = CodecContext.create('libvpx-vp9', 'r')
|
239
|
+
|
240
|
+
with av.open(self.in_f) as container:
|
241
|
+
if not context:
|
242
|
+
context = container.streams.video[0].codec_context
|
243
|
+
for packet in container.demux(video=0):
|
244
|
+
for frame in context.decode(packet):
|
245
|
+
frame = frame.to_ndarray(format=frame_format)
|
246
|
+
if frame_format == 'rgb24':
|
247
|
+
frame = np.dstack(
|
248
|
+
(frame, np.zeros(frame.shape[:2], dtype=np.uint8)+255)
|
249
|
+
)
|
250
|
+
self.frames_raw.append(frame)
|
251
|
+
|
252
|
+
def frames_import_lottie(self):
|
253
|
+
if self.in_f_ext == '.tgs':
|
254
|
+
anim = LottieAnimation.from_tgs(self.in_f)
|
255
|
+
else:
|
256
|
+
if isinstance(self.in_f, str):
|
257
|
+
anim = LottieAnimation.from_file(self.in_f)
|
258
|
+
else:
|
259
|
+
anim = LottieAnimation.from_data(self.in_f.read().decode('utf-8'))
|
260
|
+
|
261
|
+
for i in range(anim.lottie_animation_get_totalframe()):
|
262
|
+
frame = np.asarray(anim.render_pillow_frame(frame_num=i))
|
263
|
+
self.frames_raw.append(frame)
|
264
|
+
|
265
|
+
anim.lottie_animation_destroy()
|
266
|
+
|
267
|
+
def frames_resize(self, frames_in: list[np.ndarray]) -> list[np.ndarray]:
|
268
|
+
frames_out = []
|
269
|
+
|
270
|
+
for frame in frames_in:
|
271
|
+
im = Image.fromarray(frame, 'RGBA')
|
272
|
+
width, height = im.size
|
273
|
+
|
274
|
+
if self.res_w == None:
|
275
|
+
self.res_w = width
|
276
|
+
if self.res_h == None:
|
277
|
+
self.res_h = height
|
278
|
+
|
279
|
+
if width > height:
|
280
|
+
width_new = self.res_w
|
281
|
+
height_new = height * self.res_w // width
|
282
|
+
else:
|
283
|
+
height_new = self.res_h
|
284
|
+
width_new = width * self.res_h // height
|
285
|
+
im = im.resize((width_new, height_new), resample=Image.LANCZOS)
|
286
|
+
im_new = Image.new('RGBA', (self.res_w, self.res_h), (0, 0, 0, 0))
|
287
|
+
im_new.paste(
|
288
|
+
im, ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2)
|
289
|
+
)
|
290
|
+
frames_out.append(np.asarray(im_new))
|
291
|
+
|
292
|
+
return frames_out
|
293
|
+
|
294
|
+
def frames_drop(self, frames_in: list[np.ndarray]) -> list[np.ndarray]:
|
295
|
+
if not self.fps:
|
296
|
+
return [frames_in[0]]
|
297
|
+
|
298
|
+
frames_out = []
|
299
|
+
|
300
|
+
# fps_ratio: 1 frame in new anim equal to how many frame in old anim
|
301
|
+
# speed_ratio: How much to speed up / slow down
|
302
|
+
fps_ratio = self.fps_orig / self.fps
|
303
|
+
if (self.opt_comp.duration_min and
|
304
|
+
self.duration_orig < self.opt_comp.duration_min):
|
305
|
+
|
306
|
+
speed_ratio = self.duration_orig / self.opt_comp.duration_min
|
307
|
+
elif (self.opt_comp.duration_max and
|
308
|
+
self.duration_orig > self.opt_comp.duration_max):
|
309
|
+
|
310
|
+
speed_ratio = self.duration_orig / self.opt_comp.duration_max
|
311
|
+
else:
|
312
|
+
speed_ratio = 1
|
313
|
+
|
314
|
+
frame_current = 0
|
315
|
+
frame_current_float = 0
|
316
|
+
while frame_current < len(frames_in):
|
317
|
+
frames_out.append(frames_in[frame_current])
|
318
|
+
frame_current_float += fps_ratio * speed_ratio
|
319
|
+
frame_current = round(frame_current_float)
|
320
|
+
|
321
|
+
return frames_out
|
322
|
+
|
323
|
+
def frames_export(self):
|
324
|
+
if self.out_f_ext in ('.apng', '.png') and self.fps:
|
325
|
+
self.frames_export_apng()
|
326
|
+
elif self.out_f_ext == '.png':
|
327
|
+
self.frames_export_png()
|
328
|
+
elif self.out_f_ext == '.webp' and self.fps:
|
329
|
+
self.frames_export_webp()
|
330
|
+
elif self.fps:
|
331
|
+
self.frames_export_pyav()
|
332
|
+
else:
|
333
|
+
self.frames_export_pil()
|
334
|
+
|
335
|
+
def frames_export_pil(self):
|
336
|
+
image = Image.fromarray(self.frames_processed[0])
|
337
|
+
image.save(
|
338
|
+
self.tmp_f,
|
339
|
+
format=self.out_f_ext.replace('.', ''),
|
340
|
+
quality=self.quality
|
341
|
+
)
|
342
|
+
|
343
|
+
def frames_export_pyav(self):
|
344
|
+
options = {}
|
345
|
+
|
346
|
+
if isinstance(self.quality, int):
|
347
|
+
# Seems not actually working
|
348
|
+
options['quality'] = str(self.quality)
|
349
|
+
options['lossless'] = '0'
|
350
|
+
|
351
|
+
if self.out_f_ext == '.gif':
|
352
|
+
codec = 'gif'
|
353
|
+
pixel_format = 'rgb8'
|
354
|
+
options['loop'] = '0'
|
355
|
+
elif self.out_f_ext in ('.apng', '.png'):
|
356
|
+
codec = 'apng'
|
357
|
+
pixel_format = 'rgba'
|
358
|
+
options['plays'] = '0'
|
359
|
+
else:
|
360
|
+
codec = 'vp9'
|
361
|
+
pixel_format = 'yuva420p'
|
362
|
+
options['loop'] = '0'
|
363
|
+
|
364
|
+
with av.open(self.tmp_f, 'w', format=self.out_f_ext.replace('.', '')) as output:
|
365
|
+
out_stream = output.add_stream(codec, rate=self.fps, options=options)
|
366
|
+
out_stream.width = self.res_w
|
367
|
+
out_stream.height = self.res_h
|
368
|
+
out_stream.pix_fmt = pixel_format
|
369
|
+
|
370
|
+
for frame in self.frames_processed:
|
371
|
+
av_frame = av.VideoFrame.from_ndarray(frame, format='rgba')
|
372
|
+
for packet in out_stream.encode(av_frame):
|
373
|
+
output.mux(packet)
|
374
|
+
|
375
|
+
for packet in out_stream.encode():
|
376
|
+
output.mux(packet)
|
377
|
+
|
378
|
+
def frames_export_webp(self):
|
379
|
+
config = webp.WebPConfig.new(quality=self.quality)
|
380
|
+
enc = webp.WebPAnimEncoder.new(self.res_w, self.res_h)
|
381
|
+
timestamp_ms = 0
|
382
|
+
for frame in self.frames_processed:
|
383
|
+
pic = webp.WebPPicture.from_numpy(frame)
|
384
|
+
enc.encode_frame(pic, timestamp_ms, config=config)
|
385
|
+
timestamp_ms += int(1 / self.fps * 1000)
|
386
|
+
anim_data = enc.assemble(timestamp_ms)
|
387
|
+
self.tmp_f.write(anim_data.buffer())
|
388
|
+
|
389
|
+
def frames_export_png(self):
|
390
|
+
image = Image.fromarray(self.frames_processed[0], 'RGBA')
|
391
|
+
if self.color and self.color <= 256:
|
392
|
+
image_quant = image.quantize(colors=self.color, method=2)
|
393
|
+
else:
|
394
|
+
image_quant = image
|
395
|
+
with io.BytesIO() as f:
|
396
|
+
image_quant.save(f, format='png')
|
397
|
+
f.seek(0)
|
398
|
+
frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
|
399
|
+
self.tmp_f.write(frame_optimized)
|
400
|
+
|
401
|
+
def frames_export_apng(self):
|
402
|
+
frames_concat = np.concatenate(self.frames_processed)
|
403
|
+
image_concat = Image.fromarray(frames_concat, 'RGBA')
|
404
|
+
if self.color and self.color <= 256:
|
405
|
+
image_quant = image_concat.quantize(colors=self.color, method=2)
|
406
|
+
else:
|
407
|
+
image_quant = image_concat
|
408
|
+
|
409
|
+
for i in range(0, image_quant.height, self.res_h):
|
410
|
+
with io.BytesIO() as f:
|
411
|
+
crop_dimension = (0, i, image_quant.width, i+self.res_h)
|
412
|
+
image_cropped = image_quant.crop(crop_dimension)
|
413
|
+
image_cropped.save(f, format='png')
|
414
|
+
f.seek(0)
|
415
|
+
frame_optimized = oxipng.optimize_from_memory(f.read(), level=4)
|
416
|
+
image_final = Image.open(io.BytesIO(frame_optimized)).convert('RGBA')
|
417
|
+
frame_final = create_frame_from_rgba(
|
418
|
+
np.array(image_final),
|
419
|
+
image_final.width,
|
420
|
+
image_final.height
|
421
|
+
)
|
422
|
+
frame_final.delay_num = int(1000 / self.fps)
|
423
|
+
frame_final.delay_den = 1000
|
424
|
+
self.apngasm.add_frame(frame_final)
|
425
|
+
|
426
|
+
with CacheStore.get_cache_store(path=self.opt_comp.cache_dir) as tempdir:
|
427
|
+
self.apngasm.assemble(os.path.join(tempdir, f'out{self.out_f_ext}'))
|
428
|
+
|
429
|
+
with open(os.path.join(tempdir, f'out{self.out_f_ext}'), 'rb') as f:
|
430
|
+
self.tmp_f.write(f.read())
|
431
|
+
|
432
|
+
self.apngasm.reset()
|