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.
Files changed (57) hide show
  1. sticker_convert/__init__.py +1 -1
  2. sticker_convert/__main__.py +7 -4
  3. sticker_convert/cli.py +39 -31
  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 +16 -6
  8. sticker_convert/downloaders/download_signal.py +48 -32
  9. sticker_convert/downloaders/download_telegram.py +71 -26
  10. sticker_convert/gui.py +78 -129
  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 +3 -3
  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.6.dist-info → sticker_convert-2.1.7.dist-info}/METADATA +8 -9
  47. {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/RECORD +52 -50
  48. {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/WHEEL +1 -1
  49. sticker_convert/utils/converter.py +0 -407
  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.6.dist-info → sticker_convert-2.1.7.dist-info}/LICENSE +0 -0
  56. {sticker_convert-2.1.6.dist-info → sticker_convert-2.1.7.dist-info}/entry_points.txt +0 -0
  57. {sticker_convert-2.1.6.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.6'
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,25 +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
- status = flow.start()
106
+ status = job.start()
107
107
 
108
108
  if status == 1:
109
109
  self.cb_msg(msg='An error occured during this run.')
110
110
  elif status == 2:
111
111
  self.cb_msg(msg='Job cancelled.')
112
112
 
113
- def get_opt_input(self, args):
113
+ def get_opt_input(self, args) -> dict:
114
114
  download_options = {
115
115
  'auto': args.download_auto,
116
116
  'signal': args.download_signal,
@@ -134,13 +134,15 @@ class CLI:
134
134
  self.cb_msg(f'Error: Unrecognied URL input source for url: {url}')
135
135
  exit()
136
136
 
137
- self.opt_input = {
137
+ opt_input = {
138
138
  'option': download_option,
139
139
  'url': url,
140
- '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')
141
141
  }
142
142
 
143
- def get_opt_output(self, args):
143
+ return opt_input
144
+
145
+ def get_opt_output(self, args) -> dict:
144
146
  if args.export_whatsapp:
145
147
  export_option = 'whatsapp'
146
148
  elif args.export_signal:
@@ -152,14 +154,16 @@ class CLI:
152
154
  else:
153
155
  export_option = 'local'
154
156
 
155
- self.opt_output = {
157
+ opt_output = {
156
158
  'option': export_option,
157
- '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'),
158
160
  'title': args.title,
159
161
  'author': args.author
160
162
  }
161
163
 
162
- def get_opt_comp(self, args):
164
+ return opt_output
165
+
166
+ def get_opt_comp(self, args) -> dict:
163
167
  preset = args.preset
164
168
  if args.preset == 'custom':
165
169
  if sum((args.export_whatsapp, args.export_signal, args.export_telegram, args.export_imessage)) > 1:
@@ -174,7 +178,7 @@ class CLI:
174
178
  elif args.export_imessage:
175
179
  preset = 'imessage_small'
176
180
  elif args.preset == 'auto':
177
- output_option = self.opt_output['option']
181
+ output_option = self.opt_output.option
178
182
  if output_option == 'local':
179
183
  preset = 'custom'
180
184
  args.no_compress = True
@@ -186,7 +190,7 @@ class CLI:
186
190
  preset = output_option
187
191
  self.cb_msg(f'Auto compression option set to {preset}')
188
192
 
189
- self.opt_comp = {
193
+ opt_comp = {
190
194
  'preset': preset,
191
195
  'size_max': {
192
196
  'img': self.compression_presets[preset]['size_max']['img'] if args.img_size_max == None else args.img_size_max,
@@ -230,15 +234,17 @@ class CLI:
230
234
  'processes': args.processes if args.processes else math.ceil(cpu_count() / 2)
231
235
  }
232
236
 
233
- def get_opt_cred(self, args):
234
- 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')
235
241
  creds = JsonManager.load_json(creds_path)
236
242
  if creds:
237
243
  self.cb_msg('Loaded credentials from creds.json')
238
244
  else:
239
245
  creds = {}
240
246
 
241
- self.opt_cred = {
247
+ opt_cred = {
242
248
  'signal': {
243
249
  'uuid': args.signal_uuid if args.signal_uuid else creds.get('signal', {}).get('uuid'),
244
250
  'password': args.signal_password if args.signal_password else creds.get('signal', {}).get('password')
@@ -264,7 +270,7 @@ class CLI:
264
270
  auth_token = m.get_cred()
265
271
 
266
272
  if auth_token:
267
- self.opt_cred['kakao']['auth_token'] = auth_token
273
+ opt_cred['kakao']['auth_token'] = auth_token
268
274
 
269
275
  self.cb_msg(f'Got auth_token successfully: {auth_token}')
270
276
 
@@ -276,8 +282,8 @@ class CLI:
276
282
  uuid, password = m.get_cred()
277
283
 
278
284
  if uuid and password:
279
- self.opt_cred['signal']['uuid'] = uuid
280
- self.opt_cred['signal']['password'] = password
285
+ opt_cred['signal']['uuid'] = uuid
286
+ opt_cred['signal']['password'] = password
281
287
 
282
288
  self.cb_msg(f'Got uuid and password successfully: {uuid}, {password}')
283
289
  break
@@ -288,16 +294,18 @@ class CLI:
288
294
  line_cookies = m.get_cred()
289
295
 
290
296
  if line_cookies:
291
- self.opt_cred['line']['cookies'] = line_cookies
297
+ opt_cred['line']['cookies'] = line_cookies
292
298
 
293
299
  self.cb_msg('Got Line cookies successfully')
294
300
  else:
295
301
  self.cb_msg('Failed to get Line cookies. Have you logged in the web browser?')
296
302
 
297
303
  if args.save_cred:
298
- creds_path = os.path.join(CurrDir.get_config_dir(), 'creds.json')
299
- 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)
300
306
  self.cb_msg('Saved credentials to creds.json')
307
+
308
+ return opt_cred
301
309
 
302
310
  def cb_ask_str(self, msg: Optional[str] = None, initialvalue: Optional[str] = None, cli_show_initialvalue: bool = True) -> str:
303
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()