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.
Files changed (57) hide show
  1. sticker_convert/__init__.py +1 -1
  2. sticker_convert/__main__.py +7 -4
  3. sticker_convert/cli.py +42 -32
  4. sticker_convert/converter.py +432 -0
  5. sticker_convert/downloaders/download_base.py +40 -16
  6. sticker_convert/downloaders/download_kakao.py +103 -136
  7. sticker_convert/downloaders/download_line.py +30 -12
  8. sticker_convert/downloaders/download_signal.py +48 -32
  9. sticker_convert/downloaders/download_telegram.py +71 -26
  10. sticker_convert/gui.py +79 -130
  11. sticker_convert/{gui_frames → gui_components/frames}/comp_frame.py +2 -3
  12. sticker_convert/{gui_frames → gui_components/frames}/config_frame.py +3 -4
  13. sticker_convert/{gui_frames → gui_components/frames}/control_frame.py +2 -2
  14. sticker_convert/{gui_frames → gui_components/frames}/cred_frame.py +4 -4
  15. sticker_convert/{gui_frames → gui_components/frames}/input_frame.py +4 -4
  16. sticker_convert/{gui_frames → gui_components/frames}/output_frame.py +3 -3
  17. sticker_convert/{gui_frames → gui_components/frames}/progress_frame.py +1 -1
  18. sticker_convert/{utils → gui_components}/gui_utils.py +38 -21
  19. sticker_convert/{gui_windows → gui_components/windows}/advanced_compression_window.py +3 -2
  20. sticker_convert/{gui_windows → gui_components/windows}/base_window.py +3 -2
  21. sticker_convert/{gui_windows → gui_components/windows}/kakao_get_auth_window.py +3 -3
  22. sticker_convert/{gui_windows → gui_components/windows}/line_get_auth_window.py +2 -2
  23. sticker_convert/{gui_windows → gui_components/windows}/signal_get_auth_window.py +2 -2
  24. sticker_convert/{flow.py → job.py} +91 -102
  25. sticker_convert/job_option.py +301 -0
  26. sticker_convert/resources/compression.json +1 -1
  27. sticker_convert/uploaders/compress_wastickers.py +95 -74
  28. sticker_convert/uploaders/upload_base.py +16 -4
  29. sticker_convert/uploaders/upload_signal.py +100 -62
  30. sticker_convert/uploaders/upload_telegram.py +168 -128
  31. sticker_convert/uploaders/xcode_imessage.py +202 -132
  32. sticker_convert/{auth → utils/auth}/get_kakao_auth.py +7 -5
  33. sticker_convert/{auth → utils/auth}/get_line_auth.py +6 -5
  34. sticker_convert/{auth → utils/auth}/get_signal_auth.py +1 -1
  35. sticker_convert/utils/fake_cb_msg.py +5 -2
  36. sticker_convert/utils/{cache_store.py → files/cache_store.py} +7 -3
  37. sticker_convert/utils/files/dir_utils.py +64 -0
  38. sticker_convert/utils/{json_manager.py → files/json_manager.py} +5 -4
  39. sticker_convert/utils/files/metadata_handler.py +226 -0
  40. sticker_convert/utils/files/run_bin.py +58 -0
  41. sticker_convert/utils/{apple_png_normalize.py → media/apple_png_normalize.py} +23 -20
  42. sticker_convert/utils/{codec_info.py → media/codec_info.py} +41 -35
  43. sticker_convert/utils/media/decrypt_kakao.py +68 -0
  44. sticker_convert/utils/media/format_verify.py +184 -0
  45. sticker_convert/utils/url_detect.py +16 -14
  46. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/METADATA +11 -11
  47. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/RECORD +52 -50
  48. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/WHEEL +1 -1
  49. sticker_convert/utils/converter.py +0 -399
  50. sticker_convert/utils/curr_dir.py +0 -70
  51. sticker_convert/utils/format_verify.py +0 -188
  52. sticker_convert/utils/metadata_handler.py +0 -190
  53. sticker_convert/utils/run_bin.py +0 -46
  54. /sticker_convert/{gui_frames → gui_components/frames}/right_clicker.py +0 -0
  55. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/LICENSE +0 -0
  56. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/entry_points.txt +0 -0
  57. {sticker_convert-2.1.5.dist-info → sticker_convert-2.1.7.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,3 @@
1
1
  #!/usr/bin/env python3
2
2
  '''sticker-convert'''
3
- __version__ = '2.1.5'
3
+ __version__ = '2.1.7'
@@ -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('Launching GUI...')
14
- from sticker_convert.gui import GUI # type: ignore
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
- if __name__ == '__main__':
21
- main()
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 .flow import Flow # type: ignore
11
- from .utils.json_manager import JsonManager # type: ignore
12
- from .auth.get_kakao_auth import GetKakaoAuth # type: ignore
13
- from .auth.get_signal_auth import GetSignalAuth # type: ignore
14
- from .auth.get_line_auth import GetLineAuth # type: ignore
15
- from .utils.curr_dir import CurrDir # type: ignore
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
- flow = Flow(
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
- success = flow.start()
106
+ status = job.start()
107
107
 
108
- if not success:
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
- self.opt_input = {
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(CurrDir.get_curr_dir(), 'stickers_input')
140
+ 'dir': args.input_dir if args.input_dir else os.path.join(DirUtils.get_curr_dir(), 'stickers_input')
139
141
  }
140
142
 
141
- def get_opt_output(self, args):
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
- self.opt_output = {
157
+ opt_output = {
154
158
  'option': export_option,
155
- 'dir': args.output_dir if args.output_dir else os.path.join(CurrDir.get_curr_dir(), 'stickers_output'),
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
- def get_opt_comp(self, args):
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['option']
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
- self.opt_comp = {
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
- def get_opt_cred(self, args):
232
- creds_path = os.path.join(CurrDir.get_config_dir(), 'creds.json')
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
- self.opt_cred = {
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
- self.opt_cred['kakao']['auth_token'] = auth_token
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
- self.opt_cred['signal']['uuid'] = uuid
278
- self.opt_cred['signal']['password'] = password
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
- self.opt_cred['line']['cookies'] = line_cookies
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(CurrDir.get_config_dir(), 'creds.json')
297
- JsonManager.save_json(creds_path, self.opt_cred)
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()