diff --git a/compile.py b/compile.py index d95b62d..3ddc4bd 100755 --- a/compile.py +++ b/compile.py @@ -6,35 +6,67 @@ import sys from pathlib import Path -sys.path.append('./src') -from sticker_convert.__init__ import __version__ +sys.path.append("./src") +from sticker_convert.version import __version__ + +conan_archs = { + "x86_64": ["amd64", "x86_64", "x64"], + "x86": ["i386", "i686", "x86"], + "armv8": ["arm64", "aarch64", "aarch64_be", "armv8b", "armv8l"], + "ppc64le": ["ppc64le", "powerpc"], + "s390x": ["s390", "s390x"], +} + + +def get_arch() -> str: + arch = None + if os.getenv("SC_COMPILE_ARCH"): + arch = os.getenv("SC_COMPILE_ARCH") + else: + for k, v in conan_archs.items(): + if platform.machine().lower() in v: + arch = k + break + + if arch == None: + arch = platform.machine().lower() + + return arch -def osx_run_in_venv(cmd, get_stdout=False): - if Path('/bin/zsh').is_file(): - sh_cmd = ['/bin/zsh', '-c'] +def osx_run_in_venv(cmd: str, get_stdout: bool = False): + if Path("/bin/zsh").is_file(): + sh_cmd = ["/bin/zsh", "-c"] else: - sh_cmd = ['/bin/bash', '-c'] - venv_cmd = 'source venv/bin/activate && ' + sh_cmd = ["/bin/bash", "-c"] + venv_cmd = "source venv/bin/activate && " if get_stdout: - return subprocess.run(sh_cmd + [venv_cmd + cmd], stdout=subprocess.PIPE).stdout.decode() + return subprocess.run( + sh_cmd + [venv_cmd + cmd], stdout=subprocess.PIPE + ).stdout.decode() else: return subprocess.run(sh_cmd + [venv_cmd + cmd]) -def search_wheel_in_dir(package: str, dir: Path): + +def search_wheel_in_dir(package: str, dir: Path) -> Path: for i in dir.iterdir(): - if i.startswith(package): + if i.name.startswith(package): return i -def copy_if_universal(wheel_name: str, in_dir: Path, out_dir: Path): - if wheel_name.endswith('universal2.whl') or wheel_name.endswith('any.whl'): + raise RuntimeError(f"Cannot find wheel for {package}") + + +def copy_if_universal(wheel_name: Path, in_dir: Path, out_dir: Path) -> bool: + if wheel_name.name.endswith("universal2.whl") or wheel_name.name.endswith( + "any.whl" + ): src_path = Path(in_dir, wheel_name) dst_path = Path( out_dir, - wheel_name - .replace('x86_64', 'universal2') - .replace('arm64', 'universal2') + str(wheel_name) + .replace("x86_64", "universal2") + .replace("arm64", "universal2"), ) shutil.copy(src_path, dst_path) @@ -42,128 +74,156 @@ def copy_if_universal(wheel_name: str, in_dir: Path, out_dir: Path): else: return False + def create_universal_wheels(in_dir1: Path, in_dir2: Path, out_dir: Path): for wheel_name_1 in in_dir1.iterdir(): - package = wheel_name_1.split('-')[0] + package = wheel_name_1.name.split("-")[0] wheel_name_2 = search_wheel_in_dir(package, in_dir2) if copy_if_universal(wheel_name_1, in_dir1, out_dir): continue if copy_if_universal(wheel_name_2, in_dir2, out_dir): continue - + wheel_path_1 = Path(in_dir1, wheel_name_1) wheel_path_2 = Path(in_dir2, wheel_name_2) - subprocess.run(['delocate-fuse', wheel_path_1, wheel_path_2, '-w', str(out_dir)]) - print(f'Created universal wheel {wheel_path_1} {wheel_path_2}') + subprocess.run( + ["delocate-fuse", wheel_path_1, wheel_path_2, "-w", str(out_dir)] + ) + print(f"Created universal wheel {wheel_path_1} {wheel_path_2}") for wheel_name in out_dir.iterdir(): - wheel_name_new = wheel_name.replace('x86_64', 'universal2').replace('arm64', 'universal2') + wheel_name_new = wheel_name.name.replace("x86_64", "universal2").replace( + "arm64", "universal2" + ) src_path = Path(out_dir, wheel_name) dst_path = Path(out_dir, wheel_name_new) src_path.rename(dst_path) - print(f'Renamed universal wheel {dst_path}') + print(f"Renamed universal wheel {dst_path}") + def osx_install_universal2_dep(): - shutil.rmtree('wheel_arm', ignore_errors=True) - shutil.rmtree('wheel_x64', ignore_errors=True) - shutil.rmtree('wheel_universal2', ignore_errors=True) + shutil.rmtree("wheel_arm", ignore_errors=True) + shutil.rmtree("wheel_x64", ignore_errors=True) + shutil.rmtree("wheel_universal2", ignore_errors=True) - Path('wheel_arm').mkdir() - Path('wheel_x64').mkdir() - Path('wheel_universal2').mkdir() + Path("wheel_arm").mkdir() + Path("wheel_x64").mkdir() + Path("wheel_universal2").mkdir() - osx_run_in_venv('python -m pip download --require-virtualenv -r requirements.txt --platform macosx_11_0_arm64 --only-binary=:all: -d wheel_arm') - osx_run_in_venv('python -m pip download --require-virtualenv -r requirements.txt --platform macosx_11_0_x86_64 --only-binary=:all: -d wheel_x64') + osx_run_in_venv( + "python -m pip download --require-virtualenv -r requirements.txt --platform macosx_11_0_arm64 --only-binary=:all: -d wheel_arm" + ) + osx_run_in_venv( + "python -m pip download --require-virtualenv -r requirements.txt --platform macosx_11_0_x86_64 --only-binary=:all: -d wheel_x64" + ) - create_universal_wheels(Path('./wheel_arm'), Path('./wheel_x64'), Path('wheel_universal2')) - osx_run_in_venv('python -m pip install --require-virtualenv ./wheel_universal2/*') + create_universal_wheels( + Path("./wheel_arm"), Path("./wheel_x64"), Path("wheel_universal2") + ) + osx_run_in_venv("python -m pip install --require-virtualenv ./wheel_universal2/*") -def nuitka(python_bin, arch): + +def nuitka(python_bin: str, arch: str): cmd_list = [ python_bin, - '-m', - 'nuitka', - '--standalone', - '--follow-imports', - '--assume-yes-for-downloads', - '--include-data-files=src/sticker_convert/ios-message-stickers-template.zip=ios-message-stickers-template.zip', - '--include-data-dir=src/sticker_convert/resources=resources', - '--enable-plugin=tk-inter', - '--enable-plugin=multiprocessing', - '--include-package-data=signalstickers_client', - '--noinclude-data-file=tcl/opt0.4', - '--noinclude-data-file=tcl/http1.0' + "-m", + "nuitka", + "--standalone", + "--follow-imports", + "--assume-yes-for-downloads", + "--include-data-files=src/sticker_convert/ios-message-stickers-template.zip=ios-message-stickers-template.zip", + "--include-data-dir=src/sticker_convert/resources=resources", + "--enable-plugin=tk-inter", + "--enable-plugin=multiprocessing", + "--include-package-data=signalstickers_client", + "--noinclude-data-file=tcl/opt0.4", + "--noinclude-data-file=tcl/http1.0", ] - if platform.system() == 'Windows': - cmd_list.append('--windows-icon-from-ico=src/sticker_convert/resources/appicon.ico') - elif platform.system() == 'Darwin' and arch: - cmd_list.append('--disable-console') - cmd_list.append('--macos-create-app-bundle') - cmd_list.append('--macos-app-icon=src/sticker_convert/resources/appicon.icns') - cmd_list.append(f'--macos-target-arch={arch}') - cmd_list.append(f'--macos-app-version={__version__}') + if platform.system() == "Windows": + cmd_list.append( + "--windows-icon-from-ico=src/sticker_convert/resources/appicon.ico" + ) + elif platform.system() == "Darwin" and arch: + cmd_list.append("--disable-console") + cmd_list.append("--macos-create-app-bundle") + cmd_list.append("--macos-app-icon=src/sticker_convert/resources/appicon.icns") + cmd_list.append(f"--macos-target-arch={arch}") + cmd_list.append(f"--macos-app-version={__version__}") else: - cmd_list.append('--linux-icon=src/sticker_convert/resources/appicon.png') + cmd_list.append("--linux-icon=src/sticker_convert/resources/appicon.png") - cmd_list.append('src/sticker-convert.py') - if platform.system() == 'Darwin': - osx_run_in_venv(' '.join(cmd_list)) + cmd_list.append("src/sticker-convert.py") + if platform.system() == "Darwin": + osx_run_in_venv(" ".join(cmd_list)) else: subprocess.run(cmd_list, shell=True) + def win_patch(): - for i in Path('sticker-convert.dist/av.libs').iterdir(): - file_path = Path('sticker-convert.dist', i) + for i in Path("sticker-convert.dist/av.libs").iterdir(): + file_path = Path("sticker-convert.dist", i.name) if file_path.is_file(): os.remove(file_path) + def osx_patch(): # https://github.com/pyinstaller/pyinstaller/issues/5154#issuecomment-1567603461 - sticker_bin = Path('sticker-convert.app/Contents/MacOS/sticker-convert') - sticker_bin_cli = Path('sticker-convert.app/Contents/MacOS/sticker-convert-cli') + sticker_bin = Path("sticker-convert.app/Contents/MacOS/sticker-convert") + sticker_bin_cli = Path("sticker-convert.app/Contents/MacOS/sticker-convert-cli") sticker_bin.rename(sticker_bin_cli) - with open(sticker_bin, 'w+') as f: - f.write('#!/bin/bash\n') + with open(sticker_bin, "w+") as f: + f.write("#!/bin/bash\n") f.write('cd "$(dirname "$0")"\n') - f.write('open ./sticker-convert-cli') + f.write("open ./sticker-convert-cli") os.chmod(sticker_bin, 0o744) - osx_run_in_venv('codesign --force --deep -s - sticker-convert.app') + osx_run_in_venv("codesign --force --deep -s - sticker-convert.app") + def compile(): - arch = os.environ.get('SC_COMPILE_ARCH') - python_bin = Path(sys.executable).resolve() + arch = get_arch() + python_bin = str(Path(sys.executable).resolve()) - ios_stickers_path = 'src/sticker_convert/ios-message-stickers-template' - ios_stickers_zip = ios_stickers_path + '.zip' + ios_stickers_path = "src/sticker_convert/ios-message-stickers-template" + ios_stickers_zip = ios_stickers_path + ".zip" if Path(ios_stickers_zip).exists(): os.remove(ios_stickers_zip) - shutil.make_archive(ios_stickers_path, 'zip', ios_stickers_path) - - if platform.system() == 'Windows': - subprocess.run(f'{python_bin} -m pip install --upgrade pip'.split(' '), shell=True) - subprocess.run(f'{python_bin} -m pip install -r requirements-build.txt'.split(' '), shell=True) - subprocess.run(f'{python_bin} -m pip install -r requirements.txt'.split(' '), shell=True) - elif platform.system() == 'Darwin': - shutil.rmtree('venv', ignore_errors=True) - subprocess.run(f'{python_bin} -m pip install --upgrade pip delocate'.split(' ')) - subprocess.run(f'{python_bin} -m venv venv'.split(' ')) - python_bin = 'python' - osx_run_in_venv('python -m pip install -r requirements-build.txt') + shutil.make_archive(ios_stickers_path, "zip", ios_stickers_path) + + if platform.system() == "Windows": + subprocess.run( + f"{python_bin} -m pip install --upgrade pip".split(" "), shell=True + ) + subprocess.run( + f"{python_bin} -m pip install -r requirements-build.txt".split(" "), + shell=True, + ) + subprocess.run( + f"{python_bin} -m pip install -r requirements.txt".split(" "), shell=True + ) + elif platform.system() == "Darwin": + shutil.rmtree("venv", ignore_errors=True) + subprocess.run(f"{python_bin} -m pip install --upgrade pip delocate".split(" ")) + subprocess.run(f"{python_bin} -m venv venv".split(" ")) + python_bin = "python" + osx_run_in_venv("python -m pip install -r requirements-build.txt") if not arch: - osx_run_in_venv('python -m pip install --require-virtualenv -r requirements.txt') + osx_run_in_venv( + "python -m pip install --require-virtualenv -r requirements.txt" + ) else: osx_install_universal2_dep() nuitka(python_bin, arch) - if platform.system() == 'Windows': + if platform.system() == "Windows": win_patch() - elif platform.system() == 'Darwin': + elif platform.system() == "Darwin": osx_patch() -if __name__ == '__main__': - compile() \ No newline at end of file + +if __name__ == "__main__": + compile() diff --git a/scripts/opt-comp-experiment.py b/scripts/opt-comp-experiment.py index b2ab246..e5441b4 100755 --- a/scripts/opt-comp-experiment.py +++ b/scripts/opt-comp-experiment.py @@ -1,9 +1,9 @@ #!/usr/bin/env python3 -''' +""" Script for finding optimal minimal compression arguments based on compression on random pixels, which is an almost worst case scenario for compression -''' +""" import copy import csv @@ -15,65 +15,56 @@ from pathlib import Path from tempfile import TemporaryDirectory from threading import Thread +from typing import Optional, Any import numpy -from apngasm_python._apngasm_python import APNGAsm, create_frame_from_rgba +from apngasm_python._apngasm_python import APNGAsm, create_frame_from_rgba # type: ignore from PIL import Image from tqdm import tqdm os.chdir(Path(__file__).resolve().parent) -sys.path.append('../src') +sys.path.append("../src") from sticker_convert.converter import StickerConvert from sticker_convert.job_option import CompOption +from sticker_convert.utils.callback import Callback, CallbackReturn processes_max = math.ceil(cpu_count() / 2) -opt_comp_template = CompOption({ - 'size_max': { - 'img': 1, - 'vid': 1 - }, - 'format': { - 'img': '.webp', - 'vid': '.apng' - }, - 'fps': { - 'min': 1, - 'max': 1 - }, - 'res': { - 'w': { - 'min': 256, - 'max': 256 - }, - 'h': { - 'min': 256, - 'max': 256 - } - }, - 'quality': 50, - 'color': 50, - 'duration': 3000, - 'steps': 1, - 'fake_vid': False -}) +opt_comp_template = CompOption( + size_max_img=1, + size_max_vid=1, + format_img=[".webp"], + format_vid=[".apng"], + fps_min=1, + fps_max=1, + res_w_min=256, + res_w_max=256, + res_h_min=256, + res_h_max=256, + steps=1, + fake_vid=False, +) +opt_comp_template.quality = 50 +opt_comp_template.color = 50 +opt_comp_template.duration = 3000 formats = [ - ('img', '.webp'), - ('vid', '.webp'), - ('vidlong', '.webp'), - ('img', '.png'), - ('img618', '.png'), - ('vid', '.apng'), - ('vid618', '.apng'), - ('vid', '.webm'), - ('vid', '.gif') + ("img", ".webp"), + ("vid", ".webp"), + ("vidlong", ".webp"), + ("img", ".png"), + ("img618", ".png"), + ("vid", ".apng"), + ("vid618", ".apng"), + ("vid", ".webm"), + ("vid", ".gif"), ] + def generate_random_apng(res: int, fps: float, duration: float, out_f: str): apngasm = APNGAsm() - for _ in range(int(duration/1000*fps)): + for _ in range(int(duration / 1000 * fps)): im = numpy.random.rand(res, res, 4) * 255 frame = create_frame_from_rgba(im, res, res) frame.delay_num = int(1000 / fps) @@ -81,108 +72,145 @@ def generate_random_apng(res: int, fps: float, duration: float, out_f: str): apngasm.add_frame(frame) apngasm.assemble(out_f) + def generate_random_png(res: int, out_f: Path): im_numpy = numpy.random.rand(res, res, 4) * 255 - with Image.fromarray(im_numpy, 'RGBA') as im: + with Image.fromarray(im_numpy, "RGBA") as im: # type: ignore im.save(out_f) -def compress_worker(jobs_queue: Queue, results_queue: Queue): - for (in_f, out_f, opt_comp) in iter(jobs_queue.get, None): + +def compress_worker( + work_queue: Queue[Optional[tuple[Path, Path, CompOption]]], + results_queue: Queue[ + tuple[ + bool, + int, + Optional[int], + Optional[int], + list[Optional[int]], + list[Optional[int]], + ] + ], +): + for in_f, out_f, opt_comp in iter(work_queue.get, None): success, in_f, out_f, size = StickerConvert.convert( - in_f=in_f, out_f=out_f, opt_comp=opt_comp + in_f=in_f, + out_f=out_f, + opt_comp=opt_comp, + cb=Callback(), + cb_return=CallbackReturn(), + ) + results_queue.put( + ( + success, + size, + opt_comp.fps_max, + opt_comp.res_w_max, + opt_comp.quality, + opt_comp.color, + ) ) - results_queue.put((success, size, opt_comp.fps_max, opt_comp.res_w_max, opt_comp.quality, opt_comp.color)) - - jobs_queue.put(None) -def write_result(csv_path: str, results_queue: Queue, items: int): - with open(csv_path, 'w+', newline='') as f, tqdm(total=items) as progress: - fieldnames = ['size', 'fps', 'res', 'quality', 'color'] + work_queue.put(None) + + +def write_result(csv_path: str, results_queue: Queue[Any], items: int): + with open(csv_path, "w+", newline="") as f, tqdm(total=items) as progress: + fieldnames = ["size", "fps", "res", "quality", "color"] writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() - for (success, size, fps, res, quality, color) in iter(results_queue.get, None): - writer.writerow({ - 'size': size, - 'fps': fps, - 'res': res, - 'quality': quality, - 'color': color - }) + for _, size, fps, res, quality, color in iter(results_queue.get, None): + writer.writerow( + { + "size": size, + "fps": fps, + "res": res, + "quality": quality, + "color": color, + } + ) progress.update() + def main(): - os.makedirs('opt-comp-result', exist_ok=True) - - for (img_or_vid, fmt) in formats: + os.makedirs("opt-comp-result", exist_ok=True) + + for img_or_vid, fmt in formats: csv_name = f'{fmt.replace(".", "")}-{img_or_vid}.csv' - csv_path = Path('opt-comp-result', csv_name) + csv_path = Path("opt-comp-result", csv_name) if csv_path.is_file(): with open(csv_path) as f: if f.read(): - print(f'Skip generating as result already exists for {fmt} ({img_or_vid})...') + print( + f"Skip generating as result already exists for {fmt} ({img_or_vid})..." + ) continue else: os.remove(csv_path) - print(f'Generating result for compressing using preset {fmt} ({img_or_vid})...') + print(f"Generating result for compressing using preset {fmt} ({img_or_vid})...") with TemporaryDirectory() as tmpdir: - random_png_path = Path(tmpdir, 'random.png') - result_path = 'none' + fmt + random_png_path = Path(tmpdir, "random.png") + result_path = Path("none" + fmt) - rnd_res = 618 if '618' in img_or_vid else 512 - rnd_duration = 10000 if img_or_vid == 'vidlong' else 3000 + rnd_res = 618 if "618" in img_or_vid else 512 + rnd_duration = 10000 if img_or_vid == "vidlong" else 3000 - print('Generating random png...') - if 'img' in img_or_vid: + print("Generating random png...") + if "img" in img_or_vid: generate_random_png(rnd_res, random_png_path) else: - generate_random_apng(rnd_res, 60, rnd_duration, random_png_path) - print('Generated random png') + generate_random_apng(rnd_res, 60, rnd_duration, str(random_png_path)) + print("Generated random png") - if 'img' in img_or_vid: + if "img" in img_or_vid: fps_list = [1] else: fps_list = [i for i in range(10, 60, 5)] - if '618' in img_or_vid: - res_list = [618] # 618 for imessage_large + if "618" in img_or_vid: + res_list = [618] # 618 for imessage_large else: res_list = [i for i in range(256, 512, 8)] - if fmt in ('.apng', '.png'): + if fmt in (".apng", ".png"): quality_list = [95] - else: + else: quality_list = [i for i in range(50, 95, 5)] - if fmt in ('.apng', '.png'): + if fmt in (".apng", ".png"): color_list = [i for i in range(57, 257, 10)] else: color_list = [257] - - combinations = [i for i in itertools.product(fps_list, res_list, quality_list, color_list)] - jobs_queue = Queue() - results_queue = Queue() - - Thread(target=write_result, args=(csv_path, results_queue, len(combinations))).start() + combinations = [ + i + for i in itertools.product(fps_list, res_list, quality_list, color_list) + ] + + work_queue: Queue[Optional[tuple[Path, Path, CompOption]]] = Queue() + results_queue: Queue[Any] = Queue() - processes = [] + Thread( + target=write_result, args=(csv_path, results_queue, len(combinations)) + ).start() + + processes: list[Process] = [] for _ in range(processes_max): process = Process( - target=compress_worker, - args=(jobs_queue, results_queue) + target=compress_worker, args=(work_queue, results_queue) ) process.start() processes.append(process) - + for fps, res, quality, color in combinations: opt_comp = copy.deepcopy(opt_comp_template) opt_comp.size_max = None - opt_comp.format = fmt - if img_or_vid == 'vidlong': + opt_comp.format = [fmt] + if img_or_vid == "vidlong": opt_comp.duration = 10000 else: opt_comp.duration = 3000 @@ -191,14 +219,15 @@ def main(): opt_comp.quality = quality opt_comp.color = color - jobs_queue.put((random_png_path, result_path, opt_comp)) - - jobs_queue.put(None) - + work_queue.put((random_png_path, result_path, opt_comp)) + + work_queue.put(None) + for process in processes: process.join() results_queue.put(None) -if __name__ == '__main__': - main() \ No newline at end of file + +if __name__ == "__main__": + main() diff --git a/src/sticker-convert.py b/src/sticker-convert.py index 5fb8041..d5d0159 100755 --- a/src/sticker-convert.py +++ b/src/sticker-convert.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 from sticker_convert.__main__ import main -if __name__ == '__main__': - main() \ No newline at end of file +if __name__ == "__main__": + main() diff --git a/src/sticker_convert/__init__.py b/src/sticker_convert/__init__.py index 504fe9b..f269fca 100755 --- a/src/sticker_convert/__init__.py +++ b/src/sticker_convert/__init__.py @@ -1,3 +1,3 @@ #!/usr/bin/env python3 -'''sticker-convert''' -__version__ = '2.5.4' \ No newline at end of file +"""sticker-convert""" +from sticker_convert.version import __version__ # noqa diff --git a/src/sticker_convert/__main__.py b/src/sticker_convert/__main__.py index c666c2f..6fb28ad 100755 --- a/src/sticker_convert/__main__.py +++ b/src/sticker_convert/__main__.py @@ -3,13 +3,13 @@ def main(): import multiprocessing import sys - from sticker_convert.__init__ import __version__ + from sticker_convert.version import __version__ multiprocessing.freeze_support() print(f"sticker-convert {__version__}") if len(sys.argv) == 1: print("Launching GUI...") - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI GUI().gui() else: diff --git a/src/sticker_convert/cli.py b/src/sticker_convert/cli.py index 8c4b4d1..b1d9aa6 100755 --- a/src/sticker_convert/cli.py +++ b/src/sticker_convert/cli.py @@ -1,22 +1,22 @@ #!/usr/bin/env python3 import argparse +from argparse import Namespace import math +import signal +from json.decoder import JSONDecodeError from multiprocessing import cpu_count from pathlib import Path -import signal -from sticker_convert.__init__ import __version__ # type: ignore -from sticker_convert.definitions import (CONFIG_DIR, # type: ignore - DEFAULT_DIR, ROOT_DIR) -from sticker_convert.job import Job # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - InputOption, OutputOption) -from sticker_convert.utils.auth.get_kakao_auth import GetKakaoAuth # type: ignore -from sticker_convert.utils.auth.get_line_auth import GetLineAuth # type: ignore -from sticker_convert.utils.auth.get_signal_auth import GetSignalAuth # type: ignore -from sticker_convert.utils.callback import Callback # type: ignore -from sticker_convert.utils.files.json_manager import JsonManager # type: ignore -from sticker_convert.utils.url_detect import UrlDetect # type: ignore +from sticker_convert.version import __version__ +from sticker_convert.definitions import CONFIG_DIR, DEFAULT_DIR, ROOT_DIR +from sticker_convert.job import Job +from sticker_convert.job_option import CompOption, CredOption, InputOption, OutputOption +from sticker_convert.utils.auth.get_kakao_auth import GetKakaoAuth +from sticker_convert.utils.auth.get_line_auth import GetLineAuth +from sticker_convert.utils.auth.get_signal_auth import GetSignalAuth +from sticker_convert.utils.callback import Callback +from sticker_convert.utils.files.json_manager import JsonManager +from sticker_convert.utils.url_detect import UrlDetect class CLI: @@ -24,263 +24,412 @@ def __init__(self): self.cb = Callback() def cli(self): - self.help = JsonManager.load_json(ROOT_DIR / 'resources/help.json') - self.input_presets = JsonManager.load_json(ROOT_DIR / 'resources/input.json') - self.compression_presets = JsonManager.load_json(ROOT_DIR / 'resources/compression.json') - self.output_presets = JsonManager.load_json(ROOT_DIR / 'resources/output.json') - - if not (self.help and self.compression_presets and self.input_presets and self.output_presets): - self.cb.msg('Warning: preset json(s) cannot be found') + try: + self.help: dict[str, dict[str, str]] = JsonManager.load_json( + ROOT_DIR / "resources/help.json" + ) + self.input_presets = JsonManager.load_json( + ROOT_DIR / "resources/input.json" + ) + self.compression_presets = JsonManager.load_json( + ROOT_DIR / "resources/compression.json" + ) + self.output_presets = JsonManager.load_json( + ROOT_DIR / "resources/output.json" + ) + except RuntimeError as e: + self.cb.msg(e.__str__) return - parser = argparse.ArgumentParser(description='CLI for stickers-convert', formatter_class=argparse.RawTextHelpFormatter) + parser = argparse.ArgumentParser( + description="CLI for stickers-convert", + formatter_class=argparse.RawTextHelpFormatter, + ) - parser.add_argument('--version', action='version', version=__version__) - parser.add_argument('--no-confirm', dest='no_confirm', action='store_true', help=self.help['global']['no_confirm']) - - parser_input = parser.add_argument_group('Input options') - for k, v in self.help['input'].items(): + parser.add_argument("--version", action="version", version=__version__) + parser.add_argument( + "--no-confirm", + dest="no_confirm", + action="store_true", + help=self.help["global"]["no_confirm"], + ) + + parser_input = parser.add_argument_group("Input options") + for k, v in self.help["input"].items(): parser_input.add_argument(f'--{k.replace("_", "-")}', dest=k, help=v) parser_input_src = parser_input.add_mutually_exclusive_group() for k, v in self.input_presets.items(): - if k == 'local': + if k == "local": continue - parser_input_src.add_argument(f'--download-{k.replace("_", "-")}', dest=f'download_{k}', help=f'{v["help"]}\n({v["example"]})') + parser_input_src.add_argument( + f'--download-{k.replace("_", "-")}', + dest=f"download_{k}", + help=f'{v["help"]}\n({v["example"]})', + ) - parser_output = parser.add_argument_group('Output options') - for k, v in self.help['output'].items(): + parser_output = parser.add_argument_group("Output options") + for k, v in self.help["output"].items(): parser_output.add_argument(f'--{k.replace("_", "-")}', dest=k, help=v) parser_output_dst = parser_output.add_mutually_exclusive_group() for k, v in self.output_presets.items(): - if k == 'local': + if k == "local": continue - parser_output_dst.add_argument(f'--export-{k.replace("_", "-")}', dest=f'export_{k}', action='store_true', help=v['help']) - - parser_comp = parser.add_argument_group('Compression options') - parser_comp.add_argument('--no-compress', dest='no_compress', action='store_true', help=self.help['comp']['no_compress']) - parser_comp.add_argument('--preset', dest='preset', default='auto', choices=self.compression_presets.keys(), help=self.help['comp']['preset']) - flags_int = ('steps', 'processes', - 'fps_min', 'fps_max', - 'res_min', 'res_max', - 'res_w_min', 'res_w_max', - 'res_h_min', 'res_h_max', - 'quality_min', 'quality_max', - 'color_min', 'color_max', - 'duration_min', 'duration_max', - 'vid_size_max', 'img_size_max') - flags_float = ('fps_power', 'res_power', 'quality_power', 'color_power') - flags_str = ('vid_format', 'img_format', 'cache_dir', 'scale_filter', 'quantize_method') - flags_bool = ('fake_vid') - for k, v in self.help['comp'].items(): + parser_output_dst.add_argument( + f'--export-{k.replace("_", "-")}', + dest=f"export_{k}", + action="store_true", + help=v["help"], + ) + + parser_comp = parser.add_argument_group("Compression options") + parser_comp.add_argument( + "--no-compress", + dest="no_compress", + action="store_true", + help=self.help["comp"]["no_compress"], + ) + parser_comp.add_argument( + "--preset", + dest="preset", + default="auto", + choices=self.compression_presets.keys(), + help=self.help["comp"]["preset"], + ) + flags_int = ( + "steps", + "processes", + "fps_min", + "fps_max", + "res_min", + "res_max", + "res_w_min", + "res_w_max", + "res_h_min", + "res_h_max", + "quality_min", + "quality_max", + "color_min", + "color_max", + "duration_min", + "duration_max", + "vid_size_max", + "img_size_max", + ) + flags_float = ("fps_power", "res_power", "quality_power", "color_power") + flags_str = ( + "vid_format", + "img_format", + "cache_dir", + "scale_filter", + "quantize_method", + ) + flags_bool = "fake_vid" + for k, v in self.help["comp"].items(): if k in flags_int: - keyword_args = {'type': int, 'default': None} + keyword_args = {"type": int, "default": None} elif k in flags_float: - keyword_args = {'type': float, 'default': None} + keyword_args = {"type": float, "default": None} elif k in flags_str: - keyword_args = {'default': None} + keyword_args = {"default": None} elif k in flags_bool: - keyword_args = {'action': 'store_true', 'default': None} + keyword_args = {"action": "store_true", "default": None} else: continue - parser_comp.add_argument(f'--{k.replace("_", "-")}', **keyword_args, dest=k, help=v) - parser_comp.add_argument('--default-emoji', dest='default_emoji', default=self.compression_presets['custom']['default_emoji'], help=self.help['comp']['default_emoji']) + parser_comp.add_argument( + f'--{k.replace("_", "-")}', + **keyword_args, # type: ignore + dest=k, + help=v, + ) + parser_comp.add_argument( + "--default-emoji", + dest="default_emoji", + default=self.compression_presets["custom"]["default_emoji"], + help=self.help["comp"]["default_emoji"], + ) - parser_cred = parser.add_argument_group('Credentials options') - flags_bool = ('signal_get_auth', 'kakao_get_auth', 'line_get_auth') - for k, v in self.help['cred'].items(): + parser_cred = parser.add_argument_group("Credentials options") + flags_bool = ("signal_get_auth", "kakao_get_auth", "line_get_auth") + for k, v in self.help["cred"].items(): keyword_args = {} if k in flags_bool: - keyword_args = {'action': 'store_true'} - parser_cred.add_argument(f'--{k.replace("_", "-")}', **keyword_args, dest=k, help=v) + keyword_args = {"action": "store_true"} + parser_cred.add_argument( + f'--{k.replace("_", "-")}', + **keyword_args, # type: ignore + dest=k, + help=v, + ) args = parser.parse_args() - + self.cb.no_confirm = args.no_confirm - self.opt_input = InputOption(self.get_opt_input(args)) - self.opt_output = OutputOption(self.get_opt_output(args)) - self.opt_comp = CompOption(self.get_opt_comp(args)) - self.opt_cred = CredOption(self.get_opt_cred(args)) + self.opt_input = self.get_opt_input(args) + self.opt_output = self.get_opt_output(args) + self.opt_comp = self.get_opt_comp(args) + self.opt_cred = self.get_opt_cred(args) job = Job( - self.opt_input, self.opt_comp, self.opt_output, self.opt_cred, - self.cb.msg, self.cb.msg_block, self.cb.bar, self.cb.ask_bool, self.cb.ask_str + self.opt_input, + self.opt_comp, + self.opt_output, + self.opt_cred, + self.cb.msg, + self.cb.msg_block, + self.cb.bar, + self.cb.ask_bool, + self.cb.ask_str, ) signal.signal(signal.SIGINT, job.cancel) status = job.start() exit(status) - def get_opt_input(self, args) -> dict: + def get_opt_input(self, args: Namespace) -> InputOption: download_options = { - 'auto': args.download_auto, - 'signal': args.download_signal, - 'line': args.download_line, - 'telegram': args.download_telegram, - 'kakao': args.download_kakao, + "auto": args.download_auto, + "signal": args.download_signal, + "line": args.download_line, + "telegram": args.download_telegram, + "kakao": args.download_kakao, } - download_option = 'local' - url = None + download_option = "local" + url = "" for k, v in download_options.items(): if v: download_option = k url = v break - - if download_option == 'auto': + + if download_option == "auto": download_option = UrlDetect.detect(url) - self.cb.msg(f'Detected URL input source: {download_option}') + self.cb.msg(f"Detected URL input source: {download_option}") if not download_option: - self.cb.msg(f'Error: Unrecognied URL input source for url: {url}') + self.cb.msg(f"Error: Unrecognied URL input source for url: {url}") exit() - opt_input = { - 'option': download_option, - 'url': url, - 'dir': Path(args.input_dir).resolve() if args.input_dir else DEFAULT_DIR / 'stickers_input' - } + opt_input = InputOption( + option=download_option, + url=url, + dir=Path(args.input_dir).resolve() + if args.input_dir + else DEFAULT_DIR / "stickers_input", + ) return opt_input - def get_opt_output(self, args) -> dict: + def get_opt_output(self, args: Namespace) -> OutputOption: if args.export_whatsapp: - export_option = 'whatsapp' + export_option = "whatsapp" elif args.export_signal: - export_option = 'signal' + export_option = "signal" elif args.export_telegram: - export_option = 'telegram' + export_option = "telegram" elif args.export_imessage: - export_option = 'imessage' + export_option = "imessage" else: - export_option = 'local' - - opt_output = { - 'option': export_option, - 'dir': Path(args.output_dir).resolve() if args.output_dir else DEFAULT_DIR / 'stickers_output', - 'title': args.title, - 'author': args.author - } + export_option = "local" + + opt_output = OutputOption( + option=export_option, + dir=Path(args.output_dir).resolve() + if args.output_dir + else DEFAULT_DIR / "stickers_output", + title=args.title, + author=args.author, + ) return opt_output - def get_opt_comp(self, args) -> dict: - preset = args.preset - if args.preset == 'custom': - if sum((args.export_whatsapp, args.export_signal, args.export_telegram, args.export_imessage)) > 1: + def get_opt_comp(self, args: Namespace) -> CompOption: + preset: str = args.preset if args.preset else "auto" + if args.preset == "custom": + if ( + sum( + ( + args.export_whatsapp, + args.export_signal, + args.export_telegram, + args.export_imessage, + ) + ) + > 1 + ): # Let the verify functions in export do the compression args.no_compress = True elif args.export_whatsapp: - preset = 'whatsapp' + preset = "whatsapp" elif args.export_signal: - preset = 'signal' + preset = "signal" elif args.export_telegram: - preset = 'telegram' + preset = "telegram" elif args.export_imessage: - preset = 'imessage_small' - elif args.preset == 'auto': - output_option = self.opt_output.option - if output_option == 'local': - preset = 'custom' + preset = "imessage_small" + elif args.preset == "auto": + output_option = ( + self.opt_output.option if self.opt_output.option else "local" + ) + if output_option == "local": + preset = "custom" args.no_compress = True - self.cb.msg('Auto compression option set to no_compress (Reason: Export to local directory only)') - elif output_option == 'imessage': - preset = 'imessage_small' - self.cb.msg(f'Auto compression option set to {preset}') + self.cb.msg( + "Auto compression option set to no_compress (Reason: Export to local directory only)" + ) + elif output_option == "imessage": + preset = "imessage_small" + self.cb.msg(f"Auto compression option set to {preset}") else: preset = output_option - self.cb.msg(f'Auto compression option set to {preset}') - - opt_comp = { - 'preset': preset, - 'size_max': { - 'img': self.compression_presets[preset]['size_max']['img'] if args.img_size_max == None else args.img_size_max, - 'vid': self.compression_presets[preset]['size_max']['vid'] if args.vid_size_max == None else args.vid_size_max - }, - 'format': { - 'img': self.compression_presets[preset]['format']['img'] if args.img_format == None else args.img_format, - 'vid': self.compression_presets[preset]['format']['vid'] if args.vid_format == None else args.vid_format - }, - 'fps': { - 'min': self.compression_presets[preset]['fps']['min'] if args.fps_min == None else args.fps_min, - 'max': self.compression_presets[preset]['fps']['max'] if args.fps_max == None else args.fps_max, - 'power': self.compression_presets[preset]['fps']['power'] if args.fps_power == None else args.fps_power, - }, - 'res': { - 'w': { - 'min': self.compression_presets[preset]['res']['w']['min'] if args.res_w_min == None else args.res_w_min, - 'max': self.compression_presets[preset]['res']['w']['max'] if args.res_w_max == None else args.res_w_max - }, - 'h': { - 'min': self.compression_presets[preset]['res']['h']['min'] if args.res_h_min == None else args.res_h_min, - 'max': self.compression_presets[preset]['res']['h']['max'] if args.res_h_max == None else args.res_h_max - }, - 'power': self.compression_presets[preset]['res']['power'] if args.res_power == None else args.res_power, - }, - 'quality': { - 'min': self.compression_presets[preset]['quality']['min'] if args.quality_min == None else args.quality_min, - 'max': self.compression_presets[preset]['quality']['max'] if args.quality_max == None else args.quality_max, - 'power': self.compression_presets[preset]['quality']['power'] if args.quality_power == None else args.quality_power, - }, - 'color': { - 'min': self.compression_presets[preset]['color']['min'] if args.color_min == None else args.color_min, - 'max': self.compression_presets[preset]['color']['max'] if args.color_max == None else args.color_max, - 'power': self.compression_presets[preset]['color']['power'] if args.color_power == None else args.color_power, - }, - 'duration': { - 'min': self.compression_presets[preset]['duration']['min'] if args.duration_min == None else args.duration_min, - 'max': self.compression_presets[preset]['duration']['max'] if args.duration_max == None else args.duration_max - }, - 'steps': self.compression_presets[preset]['steps'] if args.steps == None else args.steps, - 'fake_vid': self.compression_presets[preset]['fake_vid'] if args.fake_vid == None else args.fake_vid, - 'cache_dir': args.cache_dir, - 'scale_filter': self.compression_presets[preset]['scale_filter'] if args.scale_filter == None else args.scale_filter, - 'quantize_method': self.compression_presets[preset]['quantize_method'] if args.quantize_method == None else args.quantize_method, - 'default_emoji': self.compression_presets[preset]['default_emoji'] if args.default_emoji == None else args.default_emoji, - 'no_compress': args.no_compress, - 'processes': args.processes if args.processes else math.ceil(cpu_count() / 2) - } + self.cb.msg(f"Auto compression option set to {preset}") + + opt_comp = CompOption( + preset=preset, + size_max_img=self.compression_presets[preset]["size_max"]["img"] + if args.img_size_max is None + else args.img_size_max, + size_max_vid=self.compression_presets[preset]["size_max"]["vid"] + if args.vid_size_max is None + else args.vid_size_max, + format_img=[ + self.compression_presets[preset]["format"]["img"] + if args.img_format is None + else args.img_format + ], + format_vid=[ + self.compression_presets[preset]["format"]["vid"] + if args.vid_format is None + else args.vid_format + ], + fps_min=self.compression_presets[preset]["fps"]["min"] + if args.fps_min is None + else args.fps_min, + fps_max=self.compression_presets[preset]["fps"]["max"] + if args.fps_max is None + else args.fps_max, + fps_power=self.compression_presets[preset]["fps"]["power"] + if args.fps_power is None + else args.fps_power, + res_w_min=self.compression_presets[preset]["res"]["w"]["min"] + if args.res_w_min is None + else args.res_w_min, + res_w_max=self.compression_presets[preset]["res"]["w"]["max"] + if args.res_w_max is None + else args.res_w_max, + res_h_min=self.compression_presets[preset]["res"]["h"]["min"] + if args.res_h_min is None + else args.res_h_min, + res_h_max=self.compression_presets[preset]["res"]["h"]["max"] + if args.res_h_max is None + else args.res_h_max, + res_power=self.compression_presets[preset]["res"]["power"] + if args.res_power is None + else args.res_power, + quality_min=self.compression_presets[preset]["quality"]["min"] + if args.quality_min is None + else args.quality_min, + quality_max=self.compression_presets[preset]["quality"]["max"] + if args.quality_max is None + else args.quality_max, + quality_power=self.compression_presets[preset]["quality"]["power"] + if args.quality_power is None + else args.quality_power, + color_min=self.compression_presets[preset]["color"]["min"] + if args.color_min is None + else args.color_min, + color_max=self.compression_presets[preset]["color"]["max"] + if args.color_max is None + else args.color_max, + color_power=self.compression_presets[preset]["color"]["power"] + if args.color_power is None + else args.color_power, + duration_min=self.compression_presets[preset]["duration"]["min"] + if args.duration_min is None + else args.duration_min, + duration_max=self.compression_presets[preset]["duration"]["max"] + if args.duration_max is None + else args.duration_max, + steps=self.compression_presets[preset]["steps"] + if args.steps is None + else args.steps, + fake_vid=self.compression_presets[preset]["fake_vid"] + if args.fake_vid is None + else args.fake_vid, + cache_dir=args.cache_dir, + scale_filter=self.compression_presets[preset]["scale_filter"] + if args.scale_filter is None + else args.scale_filter, + quantize_method=self.compression_presets[preset]["quantize_method"] + if args.quantize_method is None + else args.quantize_method, + default_emoji=self.compression_presets[preset]["default_emoji"] + if args.default_emoji is None + else args.default_emoji, + no_compress=args.no_compress, + processes=args.processes if args.processes else math.ceil(cpu_count() / 2), + ) return opt_comp - def get_opt_cred(self, args) -> dict: - creds_path = CONFIG_DIR / 'creds.json' - creds = JsonManager.load_json(creds_path) + def get_opt_cred(self, args: Namespace) -> CredOption: + creds_path = CONFIG_DIR / "creds.json" + creds = {} + try: + creds = JsonManager.load_json(creds_path) + except JSONDecodeError: + self.cb.msg("Warning: creds.json content is corrupted") + if creds: - self.cb.msg('Loaded credentials from creds.json') - else: - creds = {} - - opt_cred = { - 'signal': { - 'uuid': args.signal_uuid if args.signal_uuid else creds.get('signal', {}).get('uuid'), - 'password': args.signal_password if args.signal_password else creds.get('signal', {}).get('password') - }, - 'telegram': { - 'token': args.telegram_token if args.telegram_token else creds.get('telegram', {}).get('token'), - 'userid': args.telegram_userid if args.telegram_userid else creds.get('telegram', {}).get('userid') - }, - 'kakao': { - 'auth_token': args.kakao_auth_token if args.kakao_auth_token else creds.get('kakao', {}).get('auth_token'), - 'username': args.kakao_username if args.kakao_username else creds.get('kakao', {}).get('username'), - 'password': args.kakao_password if args.kakao_password else creds.get('kakao', {}).get('password'), - 'country_code': args.kakao_country_code if args.kakao_country_code else creds.get('kakao', {}).get('country_code'), - 'phone_number': args.kakao_phone_number if args.kakao_phone_number else creds.get('kakao', {}).get('phone_number') - }, - 'line': { - 'cookies': args.line_cookies if args.line_cookies else creds.get('line', {}).get('cookies') - } - } + self.cb.msg("Loaded credentials from creds.json") + + opt_cred = CredOption( + signal_uuid=args.signal_uuid + if args.signal_uuid + else creds.get("signal", {}).get("uuid"), + signal_password=args.signal_password + if args.signal_password + else creds.get("signal", {}).get("password"), + telegram_token=args.telegram_token + if args.telegram_token + else creds.get("telegram", {}).get("token"), + telegram_userid=args.telegram_userid + if args.telegram_userid + else creds.get("telegram", {}).get("userid"), + kakao_auth_token=args.kakao_auth_token + if args.kakao_auth_token + else creds.get("kakao", {}).get("auth_token"), + kakao_username=args.kakao_username + if args.kakao_username + else creds.get("kakao", {}).get("username"), + kakao_password=args.kakao_password + if args.kakao_password + else creds.get("kakao", {}).get("password"), + kakao_country_code=args.kakao_country_code + if args.kakao_country_code + else creds.get("kakao", {}).get("country_code"), + kakao_phone_number=args.kakao_phone_number + if args.kakao_phone_number + else creds.get("kakao", {}).get("phone_number"), + line_cookies=args.line_cookies + if args.line_cookies + else creds.get("line", {}).get("cookies"), + ) if args.kakao_get_auth: - m = GetKakaoAuth(opt_cred=CredOption(creds), cb_msg=self.cb.msg, cb_msg_block=self.cb.msg_block, cb_ask_str=self.cb.ask_str) + m = GetKakaoAuth( + opt_cred=opt_cred, + cb_msg=self.cb.msg, + cb_msg_block=self.cb.msg_block, + cb_ask_str=self.cb.ask_str, + ) auth_token = m.get_cred() if auth_token: - opt_cred['kakao']['auth_token'] = auth_token - - self.cb_msg(f'Got auth_token successfully: {auth_token}') - + opt_cred.kakao_auth_token = auth_token + + self.cb.msg(f"Got auth_token successfully: {auth_token}") + if args.signal_get_auth: m = GetSignalAuth(cb_msg=self.cb.msg, cb_ask_str=self.cb.ask_str) @@ -289,28 +438,31 @@ def get_opt_cred(self, args) -> dict: uuid, password = m.get_cred() if uuid and password: - opt_cred['signal']['uuid'] = uuid - opt_cred['signal']['password'] = password - - self.cb_msg(f'Got uuid and password successfully: {uuid}, {password}') + opt_cred.signal_uuid = uuid + opt_cred.signal_password = password + + self.cb.msg( + f"Got uuid and password successfully: {uuid}, {password}" + ) break - + if args.line_get_auth: - m = GetLineAuth(cb_msg=self.cb.msg, cb_ask_str=self.cb.ask_str) + m = GetLineAuth() line_cookies = m.get_cred() if line_cookies: - opt_cred['line']['cookies'] = line_cookies - - self.cb.msg('Got Line cookies successfully') + opt_cred.line_cookies = line_cookies + + self.cb.msg("Got Line cookies successfully") else: - self.cb.msg('Failed to get Line cookies. Have you logged in the web browser?') - + self.cb.msg( + "Failed to get Line cookies. Have you logged in the web browser?" + ) + if args.save_cred: - creds_path = CONFIG_DIR / 'creds.json' - JsonManager.save_json(creds_path, opt_cred) - self.cb.msg('Saved credentials to creds.json') - + creds_path = CONFIG_DIR / "creds.json" + JsonManager.save_json(creds_path, opt_cred.to_dict()) + self.cb.msg("Saved credentials to creds.json") + return opt_cred - \ No newline at end of file diff --git a/src/sticker_convert/converter.py b/src/sticker_convert/converter.py index b704fce..c06a8e4 100755 --- a/src/sticker_convert/converter.py +++ b/src/sticker_convert/converter.py @@ -1,29 +1,32 @@ #!/usr/bin/env python3 -import io import math import os from decimal import ROUND_HALF_UP, Decimal from fractions import Fraction -from multiprocessing.managers import BaseProxy +from io import BytesIO from pathlib import Path -from typing import Optional, Union +from queue import Queue +from typing import Optional, Union, Any +from av.video.plane import VideoPlane import numpy as np from PIL import Image from sticker_convert.job_option import CompOption -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.cache_store import CacheStore # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore -from sticker_convert.utils.media.format_verify import FormatVerify # type: ignore +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.cache_store import CacheStore +from sticker_convert.utils.media.codec_info import CodecInfo +from sticker_convert.utils.media.format_verify import FormatVerify def get_step_value( - max: Optional[int], min: Optional[int], - step: int, steps: int, - power: int = 1, - even: bool = False - ) -> Optional[int]: + max: Optional[int], + min: Optional[int], + step: int, + steps: int, + power: float = 1.0, + even: bool = False, +) -> Optional[int]: # Power should be between -1 and positive infinity # Smaller power = More 'importance' of the parameter # Power of 1 is linear relationship @@ -33,138 +36,211 @@ def get_step_value( factor = pow(step / steps, power) else: factor = 0 - - if max != None and min != None: + + if max is not None and min is not None: v = round((max - min) * step / steps * factor + min) - if even == True and v % 2 == 1: + if even is True and v % 2 == 1: return v + 1 else: return v else: return None -def useful_array(plane, bytes_per_pixel=1, dtype='uint8'): + +def useful_array( + plane: VideoPlane, bytes_per_pixel: int = 1, dtype: str = "uint8" +) -> np.ndarray[Any, Any]: total_line_size = abs(plane.line_size) useful_line_size = plane.width * bytes_per_pixel - arr = np.frombuffer(plane, np.uint8) + arr: np.ndarray = np.frombuffer(plane, np.uint8) # type: ignore if total_line_size != useful_line_size: - arr = arr.reshape(-1, total_line_size)[:, 0:useful_line_size].reshape(-1) - return arr.view(np.dtype(dtype)) + arr: np.ndarray = arr.reshape( # type: ignore + -1, total_line_size + )[:, 0:useful_line_size].reshape(-1) + return arr.view(np.dtype(dtype)) # type: ignore + class StickerConvert: - MSG_START_COMP = '[I] Start compressing {} -> {}' - MSG_SKIP_COMP = '[S] Compatible file found, skip compress and just copy {} -> {}' - MSG_COMP = ('[C] Compressing {} -> {} res={}x{}, ' - 'quality={}, fps={}, color={} (step {}-{}-{})') - MSG_REDO_COMP = '[{}] Compressed {} -> {} but size {} {} limit {}, recompressing' - MSG_DONE_COMP = '[S] Successful compression {} -> {} size {} (step {})' - MSG_FAIL_COMP = ('[F] Failed Compression {} -> {}, ' - 'cannot get below limit {} with lowest quality under current settings (Best size: {})') - - def __init__(self, - in_f: Union[Path, list[str, io.BytesIO]], - out_f: Path, - opt_comp: CompOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None - ): - - if not cb: - cb = Callback(silent=True) - cb_return = CallbackReturn() - - self.in_f: Union[io.BytesIO, Path] + MSG_START_COMP = "[I] Start compressing {} -> {}" + MSG_SKIP_COMP = "[S] Compatible file found, skip compress and just copy {} -> {}" + MSG_COMP = ( + "[C] Compressing {} -> {} res={}x{}, " + "quality={}, fps={}, color={} (step {}-{}-{})" + ) + MSG_REDO_COMP = "[{}] Compressed {} -> {} but size {} {} limit {}, recompressing" + MSG_DONE_COMP = "[S] Successful compression {} -> {} size {} (step {})" + MSG_FAIL_COMP = ( + "[F] Failed Compression {} -> {}, " + "cannot get below limit {} with lowest quality under current settings (Best size: {})" + ) + + def __init__( + self, + in_f: Union[Path, tuple[Path, BytesIO]], + out_f: Path, + opt_comp: CompOption, + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + # cb_return: CallbackReturn + ): + self.in_f: Union[BytesIO, Path] if isinstance(in_f, Path): self.in_f = in_f self.in_f_name = self.in_f.name + self.codec_info_orig: CodecInfo = CodecInfo(self.in_f) else: self.in_f = in_f[1] - self.in_f_name = Path(in_f[0]).name - - self.codec_info_orig: CodecInfo = CodecInfo(self.in_f) + self.in_f_name = Path(in_f[0]).name # type: ignore + self.codec_info_orig: CodecInfo = CodecInfo(in_f[1], Path(in_f[0]).suffix) - valid_formats = [] + valid_formats: list[str] = [] for i in opt_comp.format: - if isinstance(i, list): - valid_formats.extend(i) - elif i != None: - valid_formats.append(i) + valid_formats.extend(i) valid_ext = False - if (len(valid_formats) == 0 - or Path(out_f).suffix in valid_formats): + self.out_f = Path() + if len(valid_formats) == 0 or Path(out_f).suffix in valid_formats: self.out_f = Path(out_f) valid_ext = True - + if not valid_ext: if self.codec_info_orig.is_animated or opt_comp.fake_vid: - ext = opt_comp.format_vid + ext = opt_comp.format_vid[0] else: - ext = opt_comp.format_img + ext = opt_comp.format_img[0] self.out_f = out_f.with_suffix(ext) - + self.out_f_name: str = self.out_f.name self.cb = cb - self.frames_raw: list[np.ndarray] = [] - self.frames_processed: list[np.ndarray] = [] + self.frames_raw: list[np.ndarray[Any, Any]] = [] + self.frames_processed: list[np.ndarray[Any, Any]] = [] self.opt_comp: CompOption = opt_comp if not self.opt_comp.steps: self.opt_comp.steps = 1 - self.size: Optional[None] = 0 - self.size_max: Optional[None] = None - self.res_w: Optional[None] = None - self.res_h: Optional[None] = None - self.quality: Optional[None] = None - self.fps: Optional[None] = None - self.color: Optional[None] = None + self.size: int = 0 + self.size_max: Optional[int] = None + self.res_w: Optional[int] = None + self.res_h: Optional[int] = None + self.quality: Optional[int] = None + self.fps: Optional[Fraction] = None + self.color: Optional[int] = None - self.tmp_f: Optional[io.BytesIO] = None + self.tmp_f: BytesIO = BytesIO() self.result: Optional[bytes] = None self.result_size: int = 0 self.result_step: Optional[int] = None self.apngasm = None - + @staticmethod - def convert(in_f: Union[Path, list[str, io.BytesIO]], - out_f: Path, - opt_comp: CompOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None - ) -> tuple[bool, Path, Union[None, bytes, Path], int]: - - sticker = StickerConvert(in_f, out_f, opt_comp, cb, cb_return) + def convert( + in_f: Union[Path, tuple[Path, BytesIO]], + out_f: Path, + opt_comp: CompOption, + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, + ) -> tuple[bool, Union[BytesIO, Path], Union[None, bytes, Path], int]: + sticker = StickerConvert(in_f, out_f, opt_comp, cb) result = sticker._convert() cb.put("update_bar") return result def _convert(self): - if (FormatVerify.check_format(self.in_f, fmt=self.opt_comp.format, file_info=self.codec_info_orig) and - FormatVerify.check_file_res(self.in_f, res=self.opt_comp.res, file_info=self.codec_info_orig) and - FormatVerify.check_file_fps(self.in_f, fps=self.opt_comp.fps, file_info=self.codec_info_orig) and - FormatVerify.check_file_size(self.in_f, size=self.opt_comp.size_max, file_info=self.codec_info_orig) and - FormatVerify.check_file_duration(self.in_f, duration=self.opt_comp.duration, file_info=self.codec_info_orig)): + if ( + FormatVerify.check_format( + self.in_f, fmt=self.opt_comp.format, file_info=self.codec_info_orig + ) + and FormatVerify.check_file_res( + self.in_f, res=self.opt_comp.res, file_info=self.codec_info_orig + ) + and FormatVerify.check_file_fps( + self.in_f, fps=self.opt_comp.fps, file_info=self.codec_info_orig + ) + and FormatVerify.check_file_size( + self.in_f, size=self.opt_comp.size_max, file_info=self.codec_info_orig + ) + and FormatVerify.check_file_duration( + self.in_f, + duration=self.opt_comp.duration, + file_info=self.codec_info_orig, + ) + ): self.cb.put((self.MSG_SKIP_COMP.format(self.in_f_name, self.out_f_name))) - with open(self.in_f, 'rb') as f: - self.result = f.read() - self.result_size = os.path.getsize(self.in_f) - + if isinstance(self.in_f, Path): + with open(self.in_f, "rb") as f: + self.result = f.read() + self.result_size = os.path.getsize(self.in_f) + else: + self.result = self.in_f.read() + self.result_size = self.in_f.getbuffer().nbytes + return self.compress_done(self.result) self.cb.put((self.MSG_START_COMP.format(self.in_f_name, self.out_f_name))) - steps_list = [] + steps_list: list[tuple[Optional[int], ...]] = [] for step in range(self.opt_comp.steps, -1, -1): - steps_list.append(( - get_step_value(self.opt_comp.res_w_max, self.opt_comp.res_w_min, step, self.opt_comp.steps, self.opt_comp.res_power, True), - get_step_value(self.opt_comp.res_h_max, self.opt_comp.res_h_min, step, self.opt_comp.steps, self.opt_comp.res_power, True), - get_step_value(self.opt_comp.quality_max, self.opt_comp.quality_min, step, self.opt_comp.steps, self.opt_comp.quality_power), - get_step_value(self.opt_comp.fps_max, self.opt_comp.fps_min, step, self.opt_comp.steps, self.opt_comp.fps_power), - get_step_value(self.opt_comp.color_max, self.opt_comp.color_min, step, self.opt_comp.steps, self.opt_comp.color_power) - )) + steps_list.append( + ( + get_step_value( + self.opt_comp.res_w_max, + self.opt_comp.res_w_min, + step, + self.opt_comp.steps, + self.opt_comp.res_power, + True, + ), + get_step_value( + self.opt_comp.res_h_max, + self.opt_comp.res_h_min, + step, + self.opt_comp.steps, + self.opt_comp.res_power, + True, + ), + get_step_value( + self.opt_comp.quality_max, + self.opt_comp.quality_min, + step, + self.opt_comp.steps, + self.opt_comp.quality_power, + ), + get_step_value( + self.opt_comp.fps_max, + self.opt_comp.fps_min, + step, + self.opt_comp.steps, + self.opt_comp.fps_power, + ), + get_step_value( + self.opt_comp.color_max, + self.opt_comp.color_min, + step, + self.opt_comp.steps, + self.opt_comp.color_power, + ), + ) + ) step_lower = 0 step_upper = self.opt_comp.steps @@ -188,53 +264,62 @@ def _convert(self): self.fps = Fraction(0) self.color = param[4] - self.tmp_f = io.BytesIO() + self.tmp_f = BytesIO() msg = self.MSG_COMP.format( - self.in_f_name, self.out_f_name, - self.res_w, self.res_h, - self.quality, int(self.fps), self.color, - step_lower, step_current, step_upper - ) + self.in_f_name, + self.out_f_name, + self.res_w, + self.res_h, + self.quality, + int(self.fps), + self.color, + step_lower, + step_current, + step_upper, + ) self.cb.put(msg) - + self.frames_processed = self.frames_drop(self.frames_raw) self.frames_processed = self.frames_resize(self.frames_processed) self.frames_export() self.tmp_f.seek(0) self.size = self.tmp_f.getbuffer().nbytes - if self.codec_info_orig.is_animated == True: + if self.codec_info_orig.is_animated is True: self.size_max = self.opt_comp.size_max_vid else: self.size_max = self.opt_comp.size_max_img - if (not self.size_max or - (self.size <= self.size_max and self.size >= self.result_size)): + if not self.size_max or ( + self.size <= self.size_max and self.size >= self.result_size + ): self.result = self.tmp_f.read() self.result_size = self.size self.result_step = step_current - + if step_upper - step_lower > 1: - if self.size <= self.size_max: - sign = '<' + if self.size <= self.size_max: # type: ignore + sign = "<" step_upper = step_current else: - sign = '>' + sign = ">" step_lower = step_current step_current = int((step_lower + step_upper) / 2) self.recompress(sign) elif self.result or not self.size_max: - return self.compress_done(self.result, self.result_step) + return self.compress_done(self.result, self.result_step) # type: ignore else: return self.compress_fail() - + def recompress(self, sign: str): msg = self.MSG_REDO_COMP.format( - sign, self.in_f_name, self.out_f_name, self.size, sign, self.size_max - ) + sign, self.in_f_name, self.out_f_name, self.size, sign, self.size_max + ) self.cb.put(msg) - def compress_fail(self) -> tuple[bool, Path, Union[None, bytes, Path], int]: + def compress_fail( + self, + ) -> tuple[bool, Union[BytesIO, Path], Union[None, bytes, Path], int]: msg = self.MSG_FAIL_COMP.format( self.in_f_name, self.out_f_name, self.size_max, self.size ) @@ -242,31 +327,35 @@ def compress_fail(self) -> tuple[bool, Path, Union[None, bytes, Path], int]: return False, self.in_f, self.out_f, self.size - def compress_done(self, - data: bytes, - result_step: Optional[int] = None - ) -> tuple[bool, Path, Union[None, bytes, Path], int]: - - if self.out_f.stem == 'none': - self.out_f = None - elif self.out_f.stem == 'bytes': - self.out_f = data + def compress_done( + self, data: bytes, result_step: Optional[int] = None + ) -> tuple[bool, Union[BytesIO, Path], Union[None, bytes, Path], int]: + if self.out_f.stem == "none": + out_f = None + elif self.out_f.stem == "bytes": + out_f = data else: - with open(self.out_f, 'wb+') as f: + out_f = self.out_f + with open(self.out_f, "wb+") as f: f.write(data) - if result_step: + if result_step: msg = self.MSG_DONE_COMP.format( self.in_f_name, self.out_f_name, self.result_size, result_step ) self.cb.put(msg) - - return True, self.in_f, self.out_f, self.result_size + + return True, self.in_f, out_f, self.result_size def frames_import(self): - if self.in_f.suffix in ('.tgs', '.lottie', '.json'): + if isinstance(self.in_f, Path): + suffix = self.in_f.suffix + else: + suffix = Path(self.in_f_name).suffix + + if suffix in (".tgs", ".lottie", ".json"): self._frames_import_lottie() - elif self.in_f.suffix in ('.webp', '.apng', 'png'): + elif suffix in (".webp", ".apng", "png"): # ffmpeg do not support webp decoding (yet) # ffmpeg could fail to decode apng if file is buggy self._frames_import_pillow() @@ -276,51 +365,69 @@ def frames_import(self): def _frames_import_pillow(self): with Image.open(self.in_f) as im: # Note: im.convert("RGBA") would return rgba image of current frame only - if 'n_frames'in im.__dir__(): + if "n_frames" in im.__dir__(): for i in range(im.n_frames): im.seek(i) self.frames_raw.append(np.asarray(im.convert("RGBA"))) else: self.frames_raw.append(np.asarray(im.convert("RGBA"))) - + def _frames_import_pyav(self): - import av # type: ignore - from av.codec.context import CodecContext # type: ignore + import av + from av.codec.context import CodecContext # Crashes when handling some webm in yuv420p and convert to rgba # https://github.com/PyAV-Org/PyAV/issues/1166 - with av.open(self.in_f.as_posix()) as container: + if isinstance(self.in_f, Path): + file = self.in_f.as_posix() + else: + file = self.in_f + with av.open(file) as container: # type: ignore context = container.streams.video[0].codec_context - if context.name == 'vp8': - context = CodecContext.create('libvpx', 'r') - elif context.name == 'vp9': - context = CodecContext.create('libvpx-vp9', 'r') - - for packet in container.demux(container.streams.video): - for frame in context.decode(packet): - if frame.width % 2 != 0: - width = frame.width - 1 + if context.name == "vp8": + context = CodecContext.create("libvpx", "r") # type: ignore + elif context.name == "vp9": + context = CodecContext.create("libvpx-vp9", "r") # type: ignore + + for packet in container.demux(container.streams.video): # type: ignore + for frame in context.decode(packet): # type: ignore + if frame.width % 2 != 0: # type: ignore + width = frame.width - 1 # type: ignore else: - width = frame.width - if frame.height % 2 != 0: - height = frame.height - 1 + width = frame.width # type: ignore + if frame.height % 2 != 0: # type: ignore + height = frame.height - 1 # type: ignore else: - height = frame.height - if frame.format.name == 'yuv420p': - rgb_array = frame.to_ndarray(format='rgb24') + height = frame.height # type: ignore + if frame.format.name == "yuv420p": # type: ignore + rgb_array = frame.to_ndarray(format="rgb24") # type: ignore rgba_array = np.dstack( - (rgb_array, np.zeros(rgb_array.shape[:2], dtype=np.uint8) + 255) + ( + rgb_array, + np.zeros(rgb_array.shape[:2], dtype=np.uint8) + 255, # type: ignore + ) # type: ignore ) else: # yuva420p may cause crash # https://github.com/laggykiller/sticker-convert/issues/114 - frame = frame.reformat(width=width, height=height, format='yuva420p', dst_colorspace=1) + frame = frame.reformat( # type: ignore + width=width, # type: ignore + height=height, # type: ignore + format="yuva420p", + dst_colorspace=1, + ) # type: ignore # https://stackoverflow.com/questions/72308308/converting-yuv-to-rgb-in-python-coefficients-work-with-array-dont-work-with-n - y = useful_array(frame.planes[0]).reshape(height, width) - u = useful_array(frame.planes[1]).reshape(height // 2, width // 2) - v = useful_array(frame.planes[2]).reshape(height // 2, width // 2) - a = useful_array(frame.planes[3]).reshape(height, width) + y = useful_array(frame.planes[0]).reshape(height, width) # type: ignore + u = useful_array(frame.planes[1]).reshape( # type: ignore + height // 2, # type: ignore + width // 2, # type: ignore + ) + v = useful_array(frame.planes[2]).reshape( # type: ignore + height // 2, # type: ignore + width // 2, # type: ignore + ) + a = useful_array(frame.planes[3]).reshape(height, width) # type: ignore u = u.repeat(2, axis=0).repeat(2, axis=1) v = v.repeat(2, axis=0).repeat(2, axis=1) @@ -333,57 +440,74 @@ def _frames_import_pyav(self): yuv_array = np.concatenate((y, u, v), axis=2) yuv_array = yuv_array.astype(np.float32) - yuv_array[:, :, 0] = yuv_array[:, :, 0].clip(16, 235).astype(yuv_array.dtype) - 16 - yuv_array[:, :, 1:] = yuv_array[:, :, 1:].clip(16, 240).astype(yuv_array.dtype) - 128 - - convert = np.array([ - [1.164, 0.000, 1.793], - [1.164, -0.213, -0.533], - [1.164, 2.112, 0.000] - ]) - rgb_array = np.matmul(yuv_array, convert.T).clip(0,255).astype('uint8') + yuv_array[:, :, 0] = ( + yuv_array[:, :, 0].clip(16, 235).astype(yuv_array.dtype) + - 16 + ) + yuv_array[:, :, 1:] = ( + yuv_array[:, :, 1:].clip(16, 240).astype(yuv_array.dtype) + - 128 + ) + + convert = np.array( + [ + [1.164, 0.000, 1.793], + [1.164, -0.213, -0.533], + [1.164, 2.112, 0.000], + ] + ) + rgb_array = ( + np.matmul(yuv_array, convert.T).clip(0, 255).astype("uint8") + ) rgba_array = np.concatenate((rgb_array, a), axis=2) self.frames_raw.append(rgba_array) def _frames_import_lottie(self): - from rlottie_python import LottieAnimation # type: ignore - - if self.in_f.suffix == '.tgs': - anim = LottieAnimation.from_tgs(self.in_f) + from rlottie_python.rlottie_wrapper import LottieAnimation + + if isinstance(self.in_f, Path): + suffix = self.in_f.suffix + else: + suffix = Path(self.in_f_name).suffix + + if suffix == ".tgs": + anim = LottieAnimation.from_tgs(self.in_f) # type: ignore else: - if isinstance(self.in_f, str): - anim = LottieAnimation.from_file(self.in_f) + if isinstance(self.in_f, Path): + anim = LottieAnimation.from_file(self.in_f.as_posix()) # type: ignore else: - anim = LottieAnimation.from_data(self.in_f.read().decode('utf-8')) + anim = LottieAnimation.from_data(self.in_f.read().decode("utf-8")) # type: ignore + + for i in range(anim.lottie_animation_get_totalframe()): # type: ignore + frame = np.asarray(anim.render_pillow_frame(frame_num=i)) # type: ignore + self.frames_raw.append(frame) # type: ignore - for i in range(anim.lottie_animation_get_totalframe()): - frame = np.asarray(anim.render_pillow_frame(frame_num=i)) - self.frames_raw.append(frame) - - anim.lottie_animation_destroy() + anim.lottie_animation_destroy() # type: ignore - def frames_resize(self, frames_in: list[np.ndarray]) -> list[np.ndarray]: - frames_out = [] + def frames_resize( + self, frames_in: list[np.ndarray[Any, Any]] + ) -> list[np.ndarray[Any, Any]]: + frames_out: list[np.ndarray[Any, Any]] = [] - if self.opt_comp.scale_filter == 'nearest': + if self.opt_comp.scale_filter == "nearest": resample = Image.NEAREST - elif self.opt_comp.scale_filter == 'bilinear': + elif self.opt_comp.scale_filter == "bilinear": resample = Image.BILINEAR - elif self.opt_comp.scale_filter == 'bicubic': + elif self.opt_comp.scale_filter == "bicubic": resample = Image.BICUBIC - elif self.opt_comp.scale_filter == 'lanczos': + elif self.opt_comp.scale_filter == "lanczos": resample = Image.LANCZOS else: resample = Image.LANCZOS for frame in frames_in: - with Image.fromarray(frame, 'RGBA') as im: + with Image.fromarray(frame, "RGBA") as im: # type: ignore width, height = im.size - if self.res_w == None: + if self.res_w is None: self.res_w = width - if self.res_h == None: + if self.res_h is None: self.res_h = height if width > height: @@ -393,32 +517,42 @@ def frames_resize(self, frames_in: list[np.ndarray]) -> list[np.ndarray]: height_new = self.res_h width_new = width * self.res_h // height - with (im.resize((width_new, height_new), resample=resample) as im_resized, - Image.new('RGBA', (self.res_w, self.res_h), (0, 0, 0, 0)) as im_new): - + with ( + im.resize((width_new, height_new), resample=resample) as im_resized, + Image.new("RGBA", (self.res_w, self.res_h), (0, 0, 0, 0)) as im_new, + ): im_new.paste( - im_resized, ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2) + im_resized, + ((self.res_w - width_new) // 2, (self.res_h - height_new) // 2), ) frames_out.append(np.asarray(im_new)) - + return frames_out - - def frames_drop(self, frames_in: list[np.ndarray]) -> list[np.ndarray]: - if not self.codec_info_orig.is_animated or not self.fps or len(self.frames_processed) == 1: + + def frames_drop( + self, frames_in: list[np.ndarray[Any, Any]] + ) -> list[np.ndarray[Any, Any]]: + if ( + not self.codec_info_orig.is_animated + or not self.fps + or len(self.frames_processed) == 1 + ): return [frames_in[0]] - frames_out = [] + frames_out: list[np.ndarray[Any, Any]] = [] # fps_ratio: 1 frame in new anim equal to how many frame in old anim # speed_ratio: How much to speed up / slow down fps_ratio = self.codec_info_orig.fps / self.fps - if (self.opt_comp.duration_min and - self.codec_info_orig.duration < self.opt_comp.duration_min): - + if ( + self.opt_comp.duration_min + and self.codec_info_orig.duration < self.opt_comp.duration_min + ): speed_ratio = self.codec_info_orig.duration / self.opt_comp.duration_min - elif (self.opt_comp.duration_max and - self.codec_info_orig.duration > self.opt_comp.duration_max): - + elif ( + self.opt_comp.duration_max + and self.codec_info_orig.duration > self.opt_comp.duration_max + ): speed_ratio = self.codec_info_orig.duration / self.opt_comp.duration_max else: speed_ratio = 1 @@ -438,137 +572,144 @@ def frames_drop(self, frames_in: list[np.ndarray]) -> list[np.ndarray]: while True: frame_current_float += frame_increment frame_current = int(Decimal(frame_current_float).quantize(0, ROUND_HALF_UP)) - if (frame_current <= len(frames_in) - 1 - and not (frames_out_max and len(frames_out) == frames_out_max)): - + if frame_current <= len(frames_in) - 1 and not ( + frames_out_max and len(frames_out) == frames_out_max + ): frames_out.append(frames_in[frame_current]) else: - while (len(frames_out) == 0 - or (frames_out_min and len(frames_out) < frames_out_min)): + while len(frames_out) == 0 or ( + frames_out_min and len(frames_out) < frames_out_min + ): frames_out.append(frames_in[-1]) return frames_out def frames_export(self): is_animated = len(self.frames_processed) > 1 and self.fps - if self.out_f.suffix in ('.apng', '.png'): + if self.out_f.suffix in (".apng", ".png"): if is_animated: self._frames_export_apng() else: self._frames_export_png() - elif self.out_f.suffix == '.webp' and is_animated: + elif self.out_f.suffix == ".webp" and is_animated: self._frames_export_webp() - elif self.out_f.suffix in ('.webm', '.mp4', '.mkv') or is_animated: + elif self.out_f.suffix in (".webm", ".mp4", ".mkv") or is_animated: self._frames_export_pyav() else: self._frames_export_pil() def _frames_export_pil(self): - with Image.fromarray(self.frames_processed[0]) as im: + with Image.fromarray(self.frames_processed[0]) as im: # type: ignore im.save( self.tmp_f, - format=self.out_f.suffix.replace('.', ''), - quality=self.quality + format=self.out_f.suffix.replace(".", ""), + quality=self.quality, ) def _frames_export_pyav(self): - import av # type: ignore + import av options = {} - + if isinstance(self.quality, int): # Seems not actually working - options['quality'] = str(self.quality) - options['lossless'] = '0' - - if self.out_f.suffix == '.gif': - codec = 'gif' - pixel_format = 'rgb8' - options['loop'] = '0' - elif self.out_f.suffix in ('.apng', '.png'): - codec = 'apng' - pixel_format = 'rgba' - options['plays'] = '0' - elif self.out_f.suffix in ('.webp', '.webm', '.mkv'): - codec = 'libvpx-vp9' - pixel_format = 'yuva420p' - options['loop'] = '0' + options["quality"] = str(self.quality) + options["lossless"] = "0" + + if self.out_f.suffix == ".gif": + codec = "gif" + pixel_format = "rgb8" + options["loop"] = "0" + elif self.out_f.suffix in (".apng", ".png"): + codec = "apng" + pixel_format = "rgba" + options["plays"] = "0" + elif self.out_f.suffix in (".webp", ".webm", ".mkv"): + codec = "libvpx-vp9" + pixel_format = "yuva420p" + options["loop"] = "0" else: - codec = 'libvpx-vp9' - pixel_format = 'yuv420p' - options['loop'] = '0' - - with av.open(self.tmp_f, 'w', format=self.out_f.suffix.replace('.', '')) as output: - out_stream = output.add_stream(codec, rate=self.fps, options=options) + codec = "libvpx-vp9" + pixel_format = "yuv420p" + options["loop"] = "0" + + with av.open( # type: ignore + self.tmp_f, "w", format=self.out_f.suffix.replace(".", "") + ) as output: + out_stream = output.add_stream(codec, rate=self.fps, options=options) # type: ignore out_stream.width = self.res_w out_stream.height = self.res_h out_stream.pix_fmt = pixel_format - + for frame in self.frames_processed: - av_frame = av.VideoFrame.from_ndarray(frame, format='rgba') - for packet in out_stream.encode(av_frame): - output.mux(packet) - - for packet in out_stream.encode(): - output.mux(packet) - + av_frame = av.VideoFrame.from_ndarray(frame, format="rgba") # type: ignore + for packet in out_stream.encode(av_frame): # type: ignore + output.mux(packet) # type: ignore + + for packet in out_stream.encode(): # type: ignore + output.mux(packet) # type: ignore + def _frames_export_webp(self): import webp # type: ignore - config = webp.WebPConfig.new(quality=self.quality) - enc = webp.WebPAnimEncoder.new(self.res_w, self.res_h) + assert self.fps + + config = webp.WebPConfig.new(quality=self.quality) # type: ignore + enc = webp.WebPAnimEncoder.new(self.res_w, self.res_h) # type: ignore timestamp_ms = 0 for frame in self.frames_processed: - pic = webp.WebPPicture.from_numpy(frame) - enc.encode_frame(pic, timestamp_ms, config=config) + pic = webp.WebPPicture.from_numpy(frame) # type: ignore + enc.encode_frame(pic, timestamp_ms, config=config) # type: ignore timestamp_ms += int(1000 / self.fps) - anim_data = enc.assemble(timestamp_ms) - self.tmp_f.write(anim_data.buffer()) - + anim_data = enc.assemble(timestamp_ms) # type: ignore + self.tmp_f.write(anim_data.buffer()) # type: ignore + def _frames_export_png(self): - with Image.fromarray(self.frames_processed[0], 'RGBA') as image: + with Image.fromarray(self.frames_processed[0], "RGBA") as image: # type: ignore image_quant = self.quantize(image) - with io.BytesIO() as f: - image_quant.save(f, format='png') + with BytesIO() as f: + image_quant.save(f, format="png") f.seek(0) frame_optimized = self.optimize_png(f.read()) self.tmp_f.write(frame_optimized) def _frames_export_apng(self): - from apngasm_python._apngasm_python import (APNGAsm, - create_frame_from_rgba) + from apngasm_python._apngasm_python import APNGAsm, create_frame_from_rgba # type: ignore + + assert self.fps + assert self.res_h frames_concat = np.concatenate(self.frames_processed) - with Image.fromarray(frames_concat, 'RGBA') as image_concat: + with Image.fromarray(frames_concat, "RGBA") as image_concat: # type: ignore image_quant = self.quantize(image_concat) - if self.apngasm == None: + if self.apngasm is None: self.apngasm = APNGAsm() delay_num = int(1000 / self.fps) for i in range(0, image_quant.height, self.res_h): - with io.BytesIO() as f: - crop_dimension = (0, i, image_quant.width, i+self.res_h) + with BytesIO() as f: + crop_dimension = (0, i, image_quant.width, i + self.res_h) image_cropped = image_quant.crop(crop_dimension) - image_cropped.save(f, format='png') + image_cropped.save(f, format="png") f.seek(0) frame_optimized = self.optimize_png(f.read()) - with Image.open(io.BytesIO(frame_optimized)) as im: - image_final = im.convert('RGBA') + with Image.open(BytesIO(frame_optimized)) as im: + image_final = im.convert("RGBA") frame_final = create_frame_from_rgba( np.array(image_final), width=image_final.width, height=image_final.height, delay_num=delay_num, - delay_den=1000 + delay_den=1000, ) self.apngasm.add_frame(frame_final) - + with CacheStore.get_cache_store(path=self.opt_comp.cache_dir) as tempdir: - tmp_apng = Path(tempdir, f'out{self.out_f.suffix}') + tmp_apng = Path(tempdir, f"out{self.out_f.suffix}") self.apngasm.assemble(tmp_apng.as_posix()) - with open(tmp_apng, 'rb') as f: + with open(tmp_apng, "rb") as f: self.tmp_f.write(f.read()) self.apngasm.reset() @@ -582,66 +723,75 @@ def optimize_png(self, image_bytes: bytes) -> bytes: fix_errors=True, filter=[oxipng.RowFilter.Brute], optimize_alpha=True, - strip=oxipng.StripChunks.safe() + strip=oxipng.StripChunks.safe(), ) def quantize(self, image: Image.Image) -> Image.Image: if not (self.color and self.color <= 256): return image.copy() - if self.opt_comp.quantize_method == 'imagequant': + if self.opt_comp.quantize_method == "imagequant": return self._quantize_by_imagequant(image) - elif self.opt_comp.quantize_method == 'fastoctree': + elif self.opt_comp.quantize_method == "fastoctree": return self._quantize_by_fastoctree(image) else: return image def _quantize_by_imagequant(self, image: Image.Image) -> Image.Image: - import imagequant + import imagequant # type: ignore - dither = 1 - (self.quality - self.opt_comp.quality_min) / (self.opt_comp.quality_max - self.opt_comp.quality_min) + assert self.quality + assert self.opt_comp.quality_min + assert self.opt_comp.quality_max + assert self.color + + dither = 1 - (self.quality - self.opt_comp.quality_min) / ( + self.opt_comp.quality_max - self.opt_comp.quality_min + ) image_quant = None for i in range(self.quality, 101, 5): try: - image_quant = imagequant.quantize_pil_image( + image_quant = imagequant.quantize_pil_image( # type: ignore image, dithering_level=dither, max_colors=self.color, min_quality=self.opt_comp.quality_min, - max_quality=i + max_quality=i, ) return image_quant except RuntimeError: pass - + return image def _quantize_by_fastoctree(self, image: Image.Image) -> Image.Image: + assert self.color + return image.quantize(colors=self.color, method=2) def fix_fps(self, fps: float) -> Fraction: # After rounding fps/duration during export, # Video duration may exceed limit. # Hence we need to 'fix' the fps - if self.out_f.suffix == '.gif': + if self.out_f.suffix == ".gif": # Quote from https://www.w3.org/Graphics/GIF/spec-gif89a.txt # vii) Delay Time - If not 0, this field specifies # the number of hundredths (1/100) of a second # # For GIF, we need to adjust fps such that delay is matching to hundreths of second return self._fix_fps_duration(fps, 100) - elif self.out_f.suffix in ('.webp', '.apng', '.png'): + elif self.out_f.suffix in (".webp", ".apng", ".png"): return self._fix_fps_duration(fps, 1000) else: return self._fix_fps_pyav(fps) - + def _fix_fps_duration(self, fps: float, denominator: int) -> Fraction: delay = int(Decimal(denominator / fps).quantize(0, ROUND_HALF_UP)) - fps = Fraction(denominator, delay) - if fps > self.opt_comp.fps_max: + fps_fraction = Fraction(denominator, delay) + if self.opt_comp.fps_max and fps_fraction > self.opt_comp.fps_max: return Fraction(denominator, (delay + 1)) - elif fps < self.opt_comp.fps_min: + elif self.opt_comp.fps_min and fps_fraction < self.opt_comp.fps_min: return Fraction(denominator, (delay - 1)) - return fps + return fps_fraction def _fix_fps_pyav(self, fps: float) -> Fraction: - return Fraction(Decimal(fps).quantize(0, ROUND_HALF_UP)) \ No newline at end of file + return Fraction(Decimal(fps).quantize(0, ROUND_HALF_UP)) diff --git a/src/sticker_convert/definitions.py b/src/sticker_convert/definitions.py index d25e874..5a144a7 100644 --- a/src/sticker_convert/definitions.py +++ b/src/sticker_convert/definitions.py @@ -14,9 +14,11 @@ def get_root_dir() -> Path: return root_dir i += 1 + # Directory that can read program resources ROOT_DIR = get_root_dir() + def get_root_dir_exe() -> Path: if appimage_path := os.getenv("APPIMAGE"): return Path(appimage_path).parent @@ -27,12 +29,14 @@ def get_root_dir_exe() -> Path: and ".app/Contents/MacOS" in ROOT_DIR.as_posix() ): return (ROOT_DIR / "../../../").resolve() - + return ROOT_DIR + # Directory that contains .exe/.app/.appimage ROOT_DIR_EXE = get_root_dir_exe() + def check_root_dir_exe_writable() -> bool: if ( not os.access(ROOT_DIR_EXE.parent, os.W_OK) @@ -47,8 +51,10 @@ def check_root_dir_exe_writable() -> bool: else: return True + ROOT_DIR_EXE_WRITABLE = check_root_dir_exe_writable() + def get_default_dir() -> Path: if ROOT_DIR_EXE_WRITABLE: return ROOT_DIR_EXE @@ -60,9 +66,11 @@ def get_default_dir() -> Path: else: return home_dir + # Default directory for stickers_input and stickers_output DEFAULT_DIR = get_default_dir() + def get_config_dir() -> Path: if platform.system() == "Windows": fallback_dir = Path(os.path.expandvars("%APPDATA%\\sticker-convert")) @@ -75,5 +83,6 @@ def get_config_dir() -> Path: os.makedirs(fallback_dir, exist_ok=True) return fallback_dir + # Directory for saving configs -CONFIG_DIR = get_config_dir() \ No newline at end of file +CONFIG_DIR = get_config_dir() diff --git a/src/sticker_convert/downloaders/__init__.py b/src/sticker_convert/downloaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sticker_convert/downloaders/download_base.py b/src/sticker_convert/downloaders/download_base.py index df016f3..202e4f7 100755 --- a/src/sticker_convert/downloaders/download_base.py +++ b/src/sticker_convert/downloaders/download_base.py @@ -1,43 +1,56 @@ #!/usr/bin/env python3 from __future__ import annotations -from pathlib import Path -from multiprocessing.managers import BaseProxy -from typing import Optional, Union +from pathlib import Path +from queue import Queue +from typing import Optional, Union, Any import requests -from sticker_convert.job_option import CredOption # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore +from sticker_convert.job_option import CredOption +from sticker_convert.utils.callback import Callback, CallbackReturn + class DownloadBase: def __init__( self, url: str, out_dir: Path, - opt_cred: Optional[CredOption] = None, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, + opt_cred: Optional[CredOption], + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, Any]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ): - - if not cb: - cb = Callback(silent=True) - cb_return = CallbackReturn() - self.url: str = url self.out_dir: Path = out_dir self.opt_cred: Optional[CredOption] = opt_cred - self.cb: Union[BaseProxy, Callback, None] = cb - self.cb_return: Optional[CallbackReturn] = cb_return + self.cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, Any]]], + str, + None, + ] + ], + Callback, + ] = cb + self.cb_return: CallbackReturn = cb_return def download_multiple_files( - self, targets: list[tuple[str, str]], retries: int = 3, **kwargs + self, targets: list[tuple[str, Path]], retries: int = 3, **kwargs: Any ): # targets format: [(url1, dest2), (url2, dest2), ...] - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(targets) - })) + self.cb.put( + ("bar", None, {"set_progress_mode": "determinate", "steps": len(targets)}) + ) for url, dest in targets: self.download_file(url, dest, retries, show_progress=False, **kwargs) @@ -47,30 +60,33 @@ def download_multiple_files( def download_file( self, url: str, - dest: Optional[str] = None, + dest: Optional[Path] = None, retries: int = 3, show_progress: bool = True, - **kwargs, - ) -> Union[bool, bytes]: + **kwargs: Any, + ) -> bytes: result = b"" chunk_size = 102400 for retry in range(retries): try: response = requests.get(url, stream=True, **kwargs) - total_length = int(response.headers.get("content-length")) # type: ignore[arg-type] + total_length = int(response.headers.get("content-length")) # type: ignore if response.status_code != 200: - return False + return b"" else: self.cb.put(f"Downloading {url}") if show_progress: steps = (total_length / chunk_size) + 1 - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": int(steps) - })) + self.cb.put( + ( + "bar", + None, + {"set_progress_mode": "determinate", "steps": int(steps)}, + ) + ) for chunk in response.iter_content(chunk_size=chunk_size): if chunk: @@ -84,12 +100,12 @@ def download_file( self.cb.put(msg) if not result: - return False + return b"" elif dest: with open(dest, "wb+") as f: f.write(result) msg = f"Downloaded {url}" self.cb.put(msg) - return True + return b"" else: return result diff --git a/src/sticker_convert/downloaders/download_kakao.py b/src/sticker_convert/downloaders/download_kakao.py index bc13566..67e474c 100755 --- a/src/sticker_convert/downloaders/download_kakao.py +++ b/src/sticker_convert/downloaders/download_kakao.py @@ -5,18 +5,19 @@ import json import zipfile from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Optional, Union, Any from urllib.parse import urlparse import requests from bs4 import BeautifulSoup +from bs4.element import Tag -from sticker_convert.downloaders.download_base import DownloadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.job_option import CredOption # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.decrypt_kakao import DecryptKakao # type: ignore +from sticker_convert.downloaders.download_base import DownloadBase +from sticker_convert.job_option import CredOption +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.decrypt_kakao import DecryptKakao class MetadataKakao: @@ -27,17 +28,24 @@ def get_info_from_share_link(url: str) -> tuple[Optional[str], Optional[str]]: response = requests.get(url, headers=headers) soup = BeautifulSoup(response.text, "html.parser") - pack_title_tag = soup.find("title") + pack_title_tag = soup.find("title") # type: ignore if not pack_title_tag: return None, None - pack_title = pack_title_tag.string # type: ignore[union-attr] + pack_title: str = pack_title_tag.string # type: ignore - data_url = soup.find("a", id="app_scheme_link").get("data-url") # type: ignore[union-attr] - if not data_url: + app_scheme_link_tag = soup.find("a", id="app_scheme_link") # type: ignore + assert isinstance(app_scheme_link_tag, Tag) + + data_urls = app_scheme_link_tag.get("data-url") + if not data_urls: return None, None + elif isinstance(data_urls, list): + data_url = data_urls[0] + else: + data_url = data_urls - item_code = data_url.replace("kakaotalk://store/emoticon/", "").split("?")[0] # type: ignore[union-attr] + item_code = data_url.replace("kakaotalk://store/emoticon/", "").split("?")[0] return pack_title, item_code @@ -103,12 +111,13 @@ def get_title_from_id(item_code: str, auth_token: str) -> Optional[str]: class DownloadKakao(DownloadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(DownloadKakao, self).__init__(*args, **kwargs) self.pack_title = None self.author = None def download_stickers_kakao(self) -> bool: + auth_token = None if self.opt_cred: auth_token = self.opt_cred.kakao_auth_token @@ -130,7 +139,7 @@ def download_stickers_kakao(self) -> bool: self.pack_title = None if auth_token: - self.pack_title = MetadataKakao.get_title_from_id(item_code, auth_token) # type: ignore[arg-type] + self.pack_title = MetadataKakao.get_title_from_id(item_code, auth_token) if not self.pack_title: self.cb.put("Warning: Cannot get pack_title with auth_token.") self.cb.put( @@ -138,7 +147,7 @@ def download_stickers_kakao(self) -> bool: ) self.cb.put("Continuing without getting pack_title") - return self.download_animated(item_code) # type: ignore[arg-type] + return self.download_animated(item_code) elif urlparse(self.url).netloc == "e.kakao.com": self.pack_title = self.url.replace("https://e.kakao.com/t/", "") @@ -148,6 +157,10 @@ def download_stickers_kakao(self) -> bool: thumbnail_urls, ) = MetadataKakao.get_info_from_pack_title(self.pack_title) + assert self.author + assert title_ko + assert thumbnail_urls + if not thumbnail_urls: self.cb.put( "Download failed: Cannot download metadata for sticker pack" @@ -155,16 +168,20 @@ def download_stickers_kakao(self) -> bool: return False if auth_token: - item_code = MetadataKakao.get_item_code(title_ko, auth_token) # type: ignore[arg-type] + item_code = MetadataKakao.get_item_code(title_ko, auth_token) if item_code: return self.download_animated(item_code) else: msg = "Warning: Cannot get item code.\n" msg += "Is auth_token invalid / expired? Try to regenerate it.\n" msg += "Continue to download static stickers instead?" - self.cb.put(("msg_block", (msg,))) - response = self.cb_return.get_response() - if response == False: + self.cb.put(("msg_block", (msg,), None)) + if self.cb_return: + response = self.cb_return.get_response() + else: + response = False + + if response is False: return False return self.download_static(thumbnail_urls) @@ -178,7 +195,7 @@ def download_static(self, thumbnail_urls: str) -> bool: self.out_dir, title=self.pack_title, author=self.author ) - targets = [] + targets: list[tuple[str, Path]] = [] for num, url in enumerate(thumbnail_urls): dest = Path(self.out_dir, str(num).zfill(3) + ".png") @@ -204,10 +221,13 @@ def download_animated(self, item_code: str) -> bool: with zipfile.ZipFile(io.BytesIO(zip_file)) as zf: self.cb.put("Unzipping...") - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(zf.namelist()) - })) + self.cb.put( + ( + "bar", + None, + {"set_progress_mode": "determinate", "steps": len(zf.namelist())}, + ) + ) for num, f_path in enumerate(sorted(zf.namelist())): ext = Path(f_path).suffix @@ -233,9 +253,18 @@ def download_animated(self, item_code: str) -> bool: def start( url: str, out_dir: Path, - opt_cred: Optional[CredOption] = None, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, + opt_cred: Optional[CredOption], + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> bool: downloader = DownloadKakao(url, out_dir, opt_cred, cb, cb_return) return downloader.download_stickers_kakao() diff --git a/src/sticker_convert/downloaders/download_line.py b/src/sticker_convert/downloaders/download_line.py index e7af8f8..63960ad 100755 --- a/src/sticker_convert/downloaders/download_line.py +++ b/src/sticker_convert/downloaders/download_line.py @@ -1,57 +1,69 @@ #!/usr/bin/env python3 from __future__ import annotations -'''Reference: https://github.com/doubleplusc/Line-sticker-downloader/blob/master/sticker_dl.py''' - -import io import json import os import string import zipfile +from io import BytesIO from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Optional, Union, Any from urllib import parse import requests from bs4 import BeautifulSoup from PIL import Image -from sticker_convert.downloaders.download_base import DownloadBase # type: ignore -from sticker_convert.job_option import CredOption # type: ignore -from sticker_convert.utils.auth.get_line_auth import GetLineAuth # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.apple_png_normalize import ApplePngNormalize # type: ignore +from sticker_convert.downloaders.download_base import DownloadBase +from sticker_convert.job_option import CredOption +from sticker_convert.utils.auth.get_line_auth import GetLineAuth +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.apple_png_normalize import ApplePngNormalize + +"""Reference: https://github.com/doubleplusc/Line-sticker-downloader/blob/master/sticker_dl.py""" class MetadataLine: @staticmethod def analyze_url(url: str) -> Optional[tuple[str, str, bool]]: - region = '' + region = "" is_emoji = False - if url.startswith('line://shop/detail/'): - pack_id = url.replace('line://shop/detail/', '') + if url.startswith("line://shop/detail/"): + pack_id = url.replace("line://shop/detail/", "") if len(url) == 24 and all(c in string.hexdigits for c in url): is_emoji = True - elif url.startswith('https://store.line.me/stickershop/product/'): - pack_id = url.replace('https://store.line.me/stickershop/product/', '').split('/')[0] - region = url.replace('https://store.line.me/stickershop/product/', '').split('/')[1] - elif url.startswith('https://line.me/S/sticker'): + elif url.startswith("https://store.line.me/stickershop/product/"): + pack_id = url.replace( + "https://store.line.me/stickershop/product/", "" + ).split("/")[0] + region = url.replace( + "https://store.line.me/stickershop/product/", "" + ).split("/")[1] + elif url.startswith("https://line.me/S/sticker"): url_parsed = parse.urlparse(url) - pack_id = url.replace('https://line.me/S/sticker/', '').split('/')[0] - region = parse.parse_qs(url_parsed.query)['lang'][0] - elif url.startswith('https://store.line.me/officialaccount/event/sticker/'): - pack_id = url.replace('https://store.line.me/officialaccount/event/sticker/', '').split('/')[0] - region = url.replace('https://store.line.me/officialaccount/event/sticker/', '').split('/')[1] - elif url.startswith('https://store.line.me/emojishop/product/'): - pack_id = url.replace('https://store.line.me/emojishop/product/', '').split('/')[0] - region = url.replace('https://store.line.me/emojishop/product/', '').split('/')[1] + pack_id = url.replace("https://line.me/S/sticker/", "").split("/")[0] + region = parse.parse_qs(url_parsed.query)["lang"][0] + elif url.startswith("https://store.line.me/officialaccount/event/sticker/"): + pack_id = url.replace( + "https://store.line.me/officialaccount/event/sticker/", "" + ).split("/")[0] + region = url.replace( + "https://store.line.me/officialaccount/event/sticker/", "" + ).split("/")[1] + elif url.startswith("https://store.line.me/emojishop/product/"): + pack_id = url.replace("https://store.line.me/emojishop/product/", "").split( + "/" + )[0] + region = url.replace("https://store.line.me/emojishop/product/", "").split( + "/" + )[1] is_emoji = True - elif url.startswith('https://line.me/S/emoji'): + elif url.startswith("https://line.me/S/emoji"): url_parsed = parse.urlparse(url) - pack_id = parse.parse_qs(url_parsed.query)['id'][0] - region = parse.parse_qs(url_parsed.query)['lang'][0] + pack_id = parse.parse_qs(url_parsed.query)["id"][0] + region = parse.parse_qs(url_parsed.query)["lang"][0] is_emoji = True elif len(url) == 24 and all(c in string.hexdigits for c in url): pack_id = url @@ -62,111 +74,125 @@ def analyze_url(url: str) -> Optional[tuple[str, str, bool]]: return None return pack_id, region, is_emoji - + @staticmethod - def get_metadata_sticon(pack_id: str, region: str) -> Optional[tuple[str, str, list, str, bool]]: - pack_meta_r = requests.get(f"https://stickershop.line-scdn.net/sticonshop/v1/{pack_id}/sticon/iphone/meta.json") + def get_metadata_sticon( + pack_id: str, region: str + ) -> Optional[tuple[str, str, list[dict[str, Any]], str, bool]]: + pack_meta_r = requests.get( + f"https://stickershop.line-scdn.net/sticonshop/v1/{pack_id}/sticon/iphone/meta.json" + ) if pack_meta_r.status_code == 200: pack_meta = json.loads(pack_meta_r.text) else: return None - - if region == '': - region = 'en' - pack_store_page = requests.get(f"https://store.line.me/emojishop/product/{pack_id}/{region}") + if region == "": + region = "en" + + pack_store_page = requests.get( + f"https://store.line.me/emojishop/product/{pack_id}/{region}" + ) if pack_store_page.status_code != 200: return None - pack_store_page_soup = BeautifulSoup(pack_store_page.text, 'html.parser') + pack_store_page_soup = BeautifulSoup(pack_store_page.text, "html.parser") - title_tag = pack_store_page_soup.find(class_='mdCMN38Item01Txt') + title_tag = pack_store_page_soup.find(class_="mdCMN38Item01Txt") # type: ignore if title_tag: title = title_tag.text else: return None - - author_tag = pack_store_page_soup.find(class_='mdCMN38Item01Author') + + author_tag = pack_store_page_soup.find(class_="mdCMN38Item01Author") # type: ignore if author_tag: author = author_tag.text else: return None - files = pack_meta['orders'] + files = pack_meta["orders"] - resource_type = pack_meta.get('sticonResourceType') + resource_type = pack_meta.get("sticonResourceType") has_sound = False return title, author, files, resource_type, has_sound - + @staticmethod - def get_metadata_stickers(pack_id: str, region: str) -> Optional[tuple[str, str, list, str, bool]]: - pack_meta_r = requests.get(f"https://stickershop.line-scdn.net/stickershop/v1/product/{pack_id}/android/productInfo.meta") + def get_metadata_stickers( + pack_id: str, region: str + ) -> Optional[tuple[str, str, list[dict[str, Any]], str, bool]]: + pack_meta_r = requests.get( + f"https://stickershop.line-scdn.net/stickershop/v1/product/{pack_id}/android/productInfo.meta" + ) if pack_meta_r.status_code == 200: pack_meta = json.loads(pack_meta_r.text) else: return None - if region == '': - if 'en' in pack_meta['title']: + if region == "": + if "en" in pack_meta["title"]: # Prefer en release - region = 'en' + region = "en" else: # If no en release, use whatever comes first - region = pack_meta['title'].keys()[0] - - if region == 'zh-Hant': - region = 'zh_TW' - - title = pack_meta['title'].get('en') - if title == None: - title = pack_meta['title'][region] - - author = pack_meta['author'].get('en') - if author == None: - author = pack_meta['author'][region] - - files = pack_meta['stickers'] - - resource_type = pack_meta.get('stickerResourceType') - has_sound = pack_meta.get('hasSound') - + region = pack_meta["title"].keys()[0] + + if region == "zh-Hant": + region = "zh_TW" + + title = pack_meta["title"].get("en") + if title is None: + title = pack_meta["title"][region] + + author = pack_meta["author"].get("en") + if author is None: + author = pack_meta["author"][region] + + files = pack_meta["stickers"] + + resource_type = pack_meta.get("stickerResourceType") + has_sound = pack_meta.get("hasSound") + return title, author, files, resource_type, has_sound class DownloadLine(DownloadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(DownloadLine, self).__init__(*args, **kwargs) self.headers = { - 'referer': 'https://store.line.me', - 'user-agent': 'Android', - 'x-requested-with': 'XMLHttpRequest', - } + "referer": "https://store.line.me", + "user-agent": "Android", + "x-requested-with": "XMLHttpRequest", + } self.cookies = self.load_cookies() - self.sticker_text_dict = {} + self.sticker_text_dict: dict[int, Any] = {} def load_cookies(self) -> dict[str, str]: - cookies = {} + cookies: dict[str, str] = {} if self.opt_cred and self.opt_cred.line_cookies: line_cookies = self.opt_cred.line_cookies try: line_cookies_dict = json.loads(line_cookies) for c in line_cookies_dict: - cookies[c['name']] = c['value'] + cookies[c["name"]] = c["value"] except json.decoder.JSONDecodeError: try: - for i in line_cookies.split(';'): - c_key, c_value = i.split('=') + for i in line_cookies.split(";"): + c_key, c_value = i.split("=") cookies[c_key] = c_value except ValueError: - self.cb.put('Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"') - + self.cb.put( + 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"' + ) + if not GetLineAuth.validate_cookies(cookies): - self.cb.put('Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"') + self.cb.put( + 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"' + ) cookies = {} return cookies @@ -174,121 +200,145 @@ def load_cookies(self) -> dict[str, str]: def get_pack_url(self) -> str: # Reference: https://sora.vercel.app/line-sticker-download if self.is_emoji: - if self.resource_type == 'ANIMATION': - pack_url = f'https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package_animation.zip' + if self.resource_type == "ANIMATION": + pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package_animation.zip" else: - pack_url = f'https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package.zip' + pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package.zip" else: - if self.resource_type in ('ANIMATION', 'ANIMATION_SOUND', 'POPUP') or self.has_sound == True: - pack_url = f'https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickerpack@2x.zip' - elif self.resource_type == 'PER_STICKER_TEXT': - pack_url = f'https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_custom_plus_base@2x.zip' - elif self.resource_type == 'NAME_TEXT': - pack_url = f'https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_name_base@2x.zip' + if ( + self.resource_type in ("ANIMATION", "ANIMATION_SOUND", "POPUP") + or self.has_sound is True + ): + pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickerpack@2x.zip" + elif self.resource_type == "PER_STICKER_TEXT": + pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_custom_plus_base@2x.zip" + elif self.resource_type == "NAME_TEXT": + pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_name_base@2x.zip" else: - pack_url = f'https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickers@2x.zip' - + pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickers@2x.zip" + return pack_url - def decompress(self, zf: zipfile.ZipFile, f_path: str, num: int, prefix: str = '', suffix: str = ''): + def decompress( + self, + zf: zipfile.ZipFile, + f_path: str, + num: int, + prefix: str = "", + suffix: str = "", + ): data = zf.read(f_path) ext = Path(f_path).suffix - if ext == '.png' and not self.is_emoji and int() < 775: + if ext == ".png" and not self.is_emoji and int() < 775: data = ApplePngNormalize.normalize(data) - self.cb.put(f'Read {f_path}') - + self.cb.put(f"Read {f_path}") + out_path = Path(self.out_dir, prefix + str(num).zfill(3) + suffix + ext) - with open(out_path, 'wb') as f: + with open(out_path, "wb") as f: f.write(data) - + def decompress_emoticon(self, zip_file: bytes): - with zipfile.ZipFile(io.BytesIO(zip_file)) as zf: - self.cb.put('Unzipping...') + with zipfile.ZipFile(BytesIO(zip_file)) as zf: + self.cb.put("Unzipping...") - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(self.pack_files) - })) + self.cb.put( + ( + "bar", + None, + {"set_progress_mode": "determinate", "steps": len(self.pack_files)}, + ) + ) for num, sticker in enumerate(self.pack_files): - if self.resource_type == 'ANIMATION': - f_path = str(sticker) + '_animation.png' + if self.resource_type == "ANIMATION": + f_path = str(sticker) + "_animation.png" else: - f_path = str(sticker) + '.png' + f_path = str(sticker) + ".png" self.decompress(zf, f_path, num) - + self.cb.put("update_bar") def decompress_stickers(self, zip_file: bytes): - with zipfile.ZipFile(io.BytesIO(zip_file)) as zf: - self.cb.put('Unzipping...') - - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(self.pack_files) - })) - + with zipfile.ZipFile(BytesIO(zip_file)) as zf: + self.cb.put("Unzipping...") + + self.cb.put( + ( + "bar", + None, + {"set_progress_mode": "determinate", "steps": len(self.pack_files)}, + ) + ) + for num, sticker in enumerate(self.pack_files): - if self.resource_type in ('ANIMATION', 'ANIMATION_SOUND'): - f_path = 'animation@2x/' + str(sticker['id']) + '@2x.png' - elif self.resource_type == 'POPUP': - if sticker.get('popup', {}).get('layer') == 'BACKGROUND': - f_path = str(sticker['id']) + '@2x.png' - self.decompress(zf, f_path, num, 'preview-') - f_path = 'popup/' + str(sticker['id']) + '.png' + if self.resource_type in ("ANIMATION", "ANIMATION_SOUND"): + f_path = "animation@2x/" + str(sticker["id"]) + "@2x.png" + elif self.resource_type == "POPUP": + if sticker.get("popup", {}).get("layer") == "BACKGROUND": + f_path = str(sticker["id"]) + "@2x.png" + self.decompress(zf, f_path, num, "preview-") + f_path = "popup/" + str(sticker["id"]) + ".png" else: - f_path = str(sticker['id']) + '@2x.png' + f_path = str(sticker["id"]) + "@2x.png" self.decompress(zf, f_path, num) - - if self.resource_type == 'PER_STICKER_TEXT': + + if self.resource_type == "PER_STICKER_TEXT": self.sticker_text_dict[num] = { - 'sticker_id': sticker['id'], - 'sticker_text': sticker['customPlus']['defaultText'] - } - - elif self.resource_type == 'NAME_TEXT': + "sticker_id": sticker["id"], + "sticker_text": sticker["customPlus"]["defaultText"], + } + + elif self.resource_type == "NAME_TEXT": self.sticker_text_dict[num] = { - 'sticker_id': sticker['id'], - 'sticker_text': '' - } - + "sticker_id": sticker["id"], + "sticker_text": "", + } + if self.has_sound: - f_path = 'sound/' + str(sticker['id']) + '.m4a' + f_path = "sound/" + str(sticker["id"]) + ".m4a" self.decompress(zf, f_path, num) - + self.cb.put("update_bar") - + def edit_custom_sticker_text(self): - line_sticker_text_path = Path(self.out_dir, 'line-sticker-text.txt') + line_sticker_text_path = Path(self.out_dir, "line-sticker-text.txt") if not line_sticker_text_path.is_file(): - with open(line_sticker_text_path, 'w+', encoding='utf-8') as f: + with open(line_sticker_text_path, "w+", encoding="utf-8") as f: json.dump(self.sticker_text_dict, f, indent=4, ensure_ascii=False) - msg_block = 'The Line sticker pack you are downloading can have customized text.\n' - msg_block += 'line-sticker-text.txt has been created in input directory.\n' - msg_block += 'Please edit line-sticker-text.txt, then continue.' - self.cb.put(("msg_block", (msg_block,))) - self.cb_return.get_response() - - with open(line_sticker_text_path , "r", encoding='utf-8') as f: - self.sticker_text_dict = json.load(f) - - def get_custom_sticker_text_urls(self) -> list: - custom_sticker_text_urls = [] + msg_block = ( + "The Line sticker pack you are downloading can have customized text.\n" + ) + msg_block += "line-sticker-text.txt has been created in input directory.\n" + msg_block += "Please edit line-sticker-text.txt, then continue." + self.cb.put(("msg_block", (msg_block,), None)) + if self.cb_return: + self.cb_return.get_response() + + with open(line_sticker_text_path, "r", encoding="utf-8") as f: + self.sticker_text_dict: dict[int, Any] = json.load(f) + + def get_custom_sticker_text_urls(self) -> list[tuple[str, Path]]: + custom_sticker_text_urls: list[tuple[str, Path]] = [] name_text_key_cache: dict[str, str] = {} for num, data in self.sticker_text_dict.items(): out_path = Path(self.out_dir, str(num).zfill(3)) - sticker_id = data['sticker_id'] - sticker_text = data['sticker_text'] - - if self.resource_type == 'PER_STICKER_TEXT': - out_path_text = out_path.with_name(out_path.name + '-text.png') - custom_sticker_text_urls.append((f'https://store.line.me/overlay/sticker/{self.pack_id}/{sticker_id}/iPhone/sticker.png?text={parse.quote(sticker_text)}', out_path_text)) - - elif self.resource_type == 'NAME_TEXT' and sticker_text: - out_path_text = out_path.with_name(out_path.name + '-text.png') + sticker_id = data["sticker_id"] + sticker_text = data["sticker_text"] + + if self.resource_type == "PER_STICKER_TEXT": + out_path_text = out_path.with_name(out_path.name + "-text.png") + custom_sticker_text_urls.append( + ( + f"https://store.line.me/overlay/sticker/{self.pack_id}/{sticker_id}/iPhone/sticker.png?text={parse.quote(sticker_text)}", + out_path_text, + ) + ) + + elif self.resource_type == "NAME_TEXT" and sticker_text: + out_path_text = out_path.with_name(out_path.name + "-text.png") name_text_key = name_text_key_cache.get(sticker_text, None) if not name_text_key: name_text_key = self.get_name_text_key(sticker_text) @@ -297,17 +347,20 @@ def get_custom_sticker_text_urls(self) -> list: else: continue - custom_sticker_text_urls.append((f'https://stickershop.line-scdn.net/stickershop/v1/sticker/{sticker_id}/iPhone/overlay/name/{name_text_key}/sticker@2x.png', out_path_text)) - + custom_sticker_text_urls.append( + ( + f"https://stickershop.line-scdn.net/stickershop/v1/sticker/{sticker_id}/iPhone/overlay/name/{name_text_key}/sticker@2x.png", + out_path_text, + ) + ) + return custom_sticker_text_urls def get_name_text_key(self, sticker_text: str) -> Optional[str]: - params = { - 'text': sticker_text - } + params = {"text": sticker_text} response = requests.get( - f'https://store.line.me/api/custom-sticker/preview/{self.pack_id}/{self.region}', + f"https://store.line.me/api/custom-sticker/preview/{self.pack_id}/{self.region}", params=params, cookies=self.cookies, headers=self.headers, @@ -315,50 +368,62 @@ def get_name_text_key(self, sticker_text: str) -> Optional[str]: response_dict = json.loads(response.text) - if response_dict['errorMessage']: - self.cb.put(f"Failed to generate customized text {sticker_text} due to: {response_dict['errorMessage']}") + if response_dict["errorMessage"]: + self.cb.put( + f"Failed to generate customized text {sticker_text} due to: {response_dict['errorMessage']}" + ) return None - name_text_key = response_dict['productPayload']['customOverlayUrl'].split('name/')[-1].split('/main.png')[0] + name_text_key = ( + response_dict["productPayload"]["customOverlayUrl"] + .split("name/")[-1] + .split("/main.png")[0] + ) return name_text_key def combine_custom_text(self): for i in sorted(self.out_dir.iterdir()): - if i.endswith('-text.png'): - base_path = Path(self.out_dir, i.replace('-text.png', '.png')) - text_path = Path(self.out_dir, i) + if i.name.endswith("-text.png"): + base_path = Path(self.out_dir, i.name.replace("-text.png", ".png")) + text_path = Path(self.out_dir, i.name) with Image.open(base_path) as im: - base_img = im.convert('RGBA') - + base_img: Image.Image = im.convert("RGBA") + with Image.open(text_path) as im: - text_img = im.convert('RGBA') + text_img = im.convert("RGBA") with Image.alpha_composite(base_img, text_img) as im: im.save(base_path) os.remove(text_path) - self.cb.put(f"Combined {i.replace('-text.png', '.png')}") - + self.cb.put(f"Combined {i.name.replace('-text.png', '.png')}") + def download_stickers_line(self) -> bool: url_data = MetadataLine.analyze_url(self.url) if url_data: self.pack_id, self.region, self.is_emoji = url_data else: - self.cb.put('Download failed: Unsupported URL format') + self.cb.put("Download failed: Unsupported URL format") return False if self.is_emoji: metadata = MetadataLine.get_metadata_sticon(self.pack_id, self.region) else: metadata = MetadataLine.get_metadata_stickers(self.pack_id, self.region) - + if metadata: - self.title, self.author, self.pack_files, self.resource_type, self.has_sound = metadata + ( + self.title, + self.author, + self.pack_files, + self.resource_type, + self.has_sound, + ) = metadata else: - self.cb.put('Download failed: Failed to get metadata') + self.cb.put("Download failed: Failed to get metadata") return False MetadataHandler.set_metadata(self.out_dir, title=self.title, author=self.author) @@ -367,37 +432,51 @@ def download_stickers_line(self) -> bool: zip_file = self.download_file(pack_url) if zip_file: - self.cb.put(f'Downloaded {pack_url}') + self.cb.put(f"Downloaded {pack_url}") else: - self.cb.put(f'Cannot download {pack_url}') + self.cb.put(f"Cannot download {pack_url}") return False - + if self.is_emoji: self.decompress_emoticon(zip_file) else: self.decompress_stickers(zip_file) - custom_sticker_text_urls = [] - if self.sticker_text_dict != {} and (self.resource_type == 'PER_STICKER_TEXT' or (self.resource_type == 'NAME_TEXT' and self.cookies != {})): + custom_sticker_text_urls: list[tuple[str, Path]] = [] + if self.sticker_text_dict != {} and ( + self.resource_type == "PER_STICKER_TEXT" + or (self.resource_type == "NAME_TEXT" and self.cookies != {}) + ): self.edit_custom_sticker_text() custom_sticker_text_urls = self.get_custom_sticker_text_urls() - elif self.resource_type == 'NAME_TEXT' and self.cookies == {}: + elif self.resource_type == "NAME_TEXT" and self.cookies == {}: self.cb.put('Warning: Line "Custom stickers" is supplied as input') - self.cb.put('However, adding custom message requires Line cookies, and it is not supplied') - self.cb.put('Continuing without adding custom text to stickers') - + self.cb.put( + "However, adding custom message requires Line cookies, and it is not supplied" + ) + self.cb.put("Continuing without adding custom text to stickers") + self.download_multiple_files(custom_sticker_text_urls, headers=self.headers) self.combine_custom_text() - + return True @staticmethod def start( url: str, out_dir: Path, - opt_cred: Optional[CredOption] = None, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, + opt_cred: Optional[CredOption], + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> bool: downloader = DownloadLine(url, out_dir, opt_cred, cb, cb_return) - return downloader.download_stickers_line() \ No newline at end of file + return downloader.download_stickers_line() diff --git a/src/sticker_convert/downloaders/download_signal.py b/src/sticker_convert/downloaders/download_signal.py index 992e1ec..fd36975 100755 --- a/src/sticker_convert/downloaders/download_signal.py +++ b/src/sticker_convert/downloaders/download_signal.py @@ -1,45 +1,51 @@ #!/usr/bin/env python3 from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Optional, Union, Any import anyio from signalstickers_client import StickersClient # type: ignore from signalstickers_client.errors import SignalException # type: ignore from signalstickers_client.models import StickerPack # type: ignore -from sticker_convert.downloaders.download_base import DownloadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.job_option import CredOption # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore +from sticker_convert.downloaders.download_base import DownloadBase +from sticker_convert.job_option import CredOption +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.codec_info import CodecInfo class DownloadSignal(DownloadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(DownloadSignal, self).__init__(*args, **kwargs) @staticmethod async def get_pack(pack_id: str, pack_key: str) -> StickerPack: async with StickersClient() as client: - pack = await client.get_pack(pack_id, pack_key) + pack = await client.get_pack(pack_id, pack_key) # type: ignore return pack def save_stickers(self, pack: StickerPack): - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(pack.stickers) - })) - - emoji_dict = {} - for sticker in pack.stickers: - f_id = str(sticker.id).zfill(3) + self.cb.put( + ( + "bar", + None, + { + "set_progress_mode": "determinate", + "steps": len(pack.stickers), # type: ignore + }, + ) + ) + + emoji_dict: dict[str, str] = {} + for sticker in pack.stickers: # type: ignore + f_id = str(sticker.id).zfill(3) # type: ignore f_path = Path(self.out_dir, f_id) with open(f_path, "wb") as f: - f.write(sticker.image_data) + f.write(sticker.image_data) # type: ignore - emoji_dict[f_id] = sticker.emoji + emoji_dict[f_id] = sticker.emoji # type: ignore codec = CodecInfo.get_file_codec(f_path) if codec == "": @@ -69,6 +75,8 @@ def download_stickers_signal(self) -> bool: pack = anyio.run(DownloadSignal.get_pack, pack_id, pack_key) except SignalException as e: self.cb.put(f"Failed to download pack due to {repr(e)}") + return False + self.save_stickers(pack) return True @@ -77,11 +85,18 @@ def download_stickers_signal(self) -> bool: def start( url: str, out_dir: Path, - opt_cred: Optional[CredOption] = None, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, + opt_cred: Optional[CredOption], + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> bool: - downloader = DownloadSignal( - url, out_dir, opt_cred, cb, cb_return - ) + downloader = DownloadSignal(url, out_dir, opt_cred, cb, cb_return) return downloader.download_stickers_signal() diff --git a/src/sticker_convert/downloaders/download_telegram.py b/src/sticker_convert/downloaders/download_telegram.py index ac67572..300c186 100755 --- a/src/sticker_convert/downloaders/download_telegram.py +++ b/src/sticker_convert/downloaders/download_telegram.py @@ -1,43 +1,46 @@ #!/usr/bin/env python3 from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Optional, Union, Any from urllib.parse import urlparse import anyio from telegram import Bot from telegram.error import TelegramError -from sticker_convert.downloaders.download_base import DownloadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.job_option import CredOption # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore +from sticker_convert.downloaders.download_base import DownloadBase +from sticker_convert.job_option import CredOption +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler class DownloadTelegram(DownloadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(DownloadTelegram, self).__init__(*args, **kwargs) def download_stickers_telegram(self) -> bool: - self.token = self.opt_cred.telegram_token - if self.token == None: + self.token = "" + + if self.opt_cred: + self.token = self.opt_cred.telegram_token.strip() + if not self.token: self.cb.put("Download failed: Token required for downloading from telegram") return False if not ("telegram.me" in self.url or "t.me" in self.url): self.cb.put("Download failed: Unrecognized URL format") return False - + self.title = Path(urlparse(self.url).path).name return anyio.run(self.save_stickers) async def save_stickers(self) -> bool: - bot = Bot(self.token.strip()) + bot = Bot(self.token) async with bot: try: sticker_set = await bot.get_sticker_set( - self.title, + self.title, # type: ignore read_timeout=30, write_timeout=30, connect_timeout=30, @@ -49,10 +52,16 @@ async def save_stickers(self) -> bool: ) return False - self.cb.put(("bar", None, { - "set_progress_mode": "determinate", - "steps": len(sticker_set.stickers) - })) + self.cb.put( + ( + "bar", + None, + { + "set_progress_mode": "determinate", + "steps": len(sticker_set.stickers), + }, + ) + ) emoji_dict = {} for num, i in enumerate(sticker_set.stickers): @@ -105,11 +114,18 @@ async def save_stickers(self) -> bool: def start( url: str, out_dir: Path, - opt_cred: Optional[CredOption] = None, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, + opt_cred: Optional[CredOption], + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> bool: - downloader = DownloadTelegram( - url, out_dir, opt_cred, cb, cb_return - ) + downloader = DownloadTelegram(url, out_dir, opt_cred, cb, cb_return) return downloader.download_stickers_telegram() diff --git a/src/sticker_convert/gui.py b/src/sticker_convert/gui.py index c44ef15..36d39ae 100755 --- a/src/sticker_convert/gui.py +++ b/src/sticker_convert/gui.py @@ -2,42 +2,40 @@ import math import os import platform +import signal import sys from functools import partial -from multiprocessing import cpu_count, Event +from json.decoder import JSONDecodeError +from multiprocessing import Event, cpu_count from pathlib import Path -import signal -from threading import Lock, Thread, current_thread, main_thread -from typing import Any, Optional, Callable +from threading import Lock, Thread +from typing import Any, Callable, Optional, Union from urllib.parse import urlparse from PIL import ImageFont -from ttkbootstrap import (BooleanVar, DoubleVar, IntVar, # type: ignore - StringVar, Window) +from ttkbootstrap import BooleanVar, DoubleVar, IntVar, StringVar, Window, Toplevel # type: ignore from ttkbootstrap.dialogs import Messagebox, Querybox # type: ignore -from sticker_convert.__init__ import __version__ # type: ignore -from sticker_convert.definitions import (CONFIG_DIR, # type: ignore - DEFAULT_DIR, ROOT_DIR) -from sticker_convert.gui_components.frames.comp_frame import CompFrame # type: ignore -from sticker_convert.gui_components.frames.config_frame import ConfigFrame # type: ignore -from sticker_convert.gui_components.frames.control_frame import ControlFrame # type: ignore -from sticker_convert.gui_components.frames.cred_frame import CredFrame # type: ignore -from sticker_convert.gui_components.frames.input_frame import InputFrame # type: ignore -from sticker_convert.gui_components.frames.output_frame import OutputFrame # type: ignore -from sticker_convert.gui_components.frames.progress_frame import ProgressFrame # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore -from sticker_convert.job import Job # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - InputOption, OutputOption) -from sticker_convert.utils.files.json_manager import JsonManager # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.url_detect import UrlDetect # type: ignore +from sticker_convert.version import __version__ +from sticker_convert.definitions import CONFIG_DIR, DEFAULT_DIR, ROOT_DIR +from sticker_convert.gui_components.frames.comp_frame import CompFrame +from sticker_convert.gui_components.frames.config_frame import ConfigFrame +from sticker_convert.gui_components.frames.control_frame import ControlFrame +from sticker_convert.gui_components.frames.cred_frame import CredFrame +from sticker_convert.gui_components.frames.input_frame import InputFrame +from sticker_convert.gui_components.frames.output_frame import OutputFrame +from sticker_convert.gui_components.frames.progress_frame import ProgressFrame +from sticker_convert.gui_components.gui_utils import GUIUtils +from sticker_convert.job import Job +from sticker_convert.job_option import CompOption, CredOption, InputOption, OutputOption +from sticker_convert.utils.files.json_manager import JsonManager +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.url_detect import UrlDetect class GUI(Window): def __init__(self): - super(GUI, self).__init__(themename='darkly', alpha=0) + super(GUI, self).__init__(themename="darkly", alpha=0) # type: ignore self.init_done = False self.load_jsons() @@ -46,8 +44,8 @@ def __init__(self): GUIUtils.set_icon(self) - self.title(f'sticker-convert {__version__}') - self.protocol('WM_DELETE_WINDOW', self.quit) + self.title(f"sticker-convert {__version__}") + self.protocol("WM_DELETE_WINDOW", self.quit) ( self.main_frame, @@ -55,7 +53,7 @@ def __init__(self): self.canvas, self.x_scrollbar, self.y_scrollbar, - self.scrollable_frame + self.scrollable_frame, ) = GUIUtils.create_scrollable_frame(self) self.declare_variables() @@ -67,7 +65,7 @@ def __init__(self): GUIUtils.finalize_window(self) self.bind("<>", self.exec_in_main) - + def __enter__(self): return self @@ -78,22 +76,22 @@ def gui(self): def quit(self): if self.job: - response = self.cb_ask_bool('Job is running, really quit?') - if response == False: + response = self.cb_ask_bool("Job is running, really quit?") + if response is False: return - self.cb_msg(msg='Quitting, please wait...') + self.cb_msg(msg="Quitting, please wait...") self.save_config() - if self.settings_save_cred_var.get() == True: + if self.settings_save_cred_var.get() is True: self.save_creds() else: self.delete_creds() - + if self.job: self.cancel_job() self.destroy() - + def declare_variables(self): # Input self.input_option_display_var = StringVar(self) @@ -136,7 +134,7 @@ def declare_variables(self): self.quantize_method_var = StringVar(self) self.cache_dir_var = StringVar(self) self.default_emoji_var = StringVar(self) - self.steps_var = IntVar(self) + self.steps_var = IntVar(self) self.processes_var = IntVar(self) # Output @@ -166,298 +164,386 @@ def declare_variables(self): self.bar_lock = Lock() self.response_event = Event() self.response = None - self.action: Optional[Callable] = None + self.action: Optional[Callable[..., Any]] = None self.job = None def init_frames(self): - self.input_frame = InputFrame(self, self.scrollable_frame, borderwidth=1, text='Input') - self.comp_frame = CompFrame(self, self.scrollable_frame, borderwidth=1, text='Compression options') - self.output_frame = OutputFrame(self, self.scrollable_frame, borderwidth=1, text='Output') - self.cred_frame = CredFrame(self, self.scrollable_frame, borderwidth=1, text='Credentials') - self.settings_frame = ConfigFrame(self, self.scrollable_frame, borderwidth=1, text='Config') - self.progress_frame = ProgressFrame(self, self.scrollable_frame, borderwidth=1, text='Progress') + self.input_frame = InputFrame( + self, self.scrollable_frame, borderwidth=1, text="Input" + ) + self.comp_frame = CompFrame( + self, self.scrollable_frame, borderwidth=1, text="Compression options" + ) + self.output_frame = OutputFrame( + self, self.scrollable_frame, borderwidth=1, text="Output" + ) + self.cred_frame = CredFrame( + self, self.scrollable_frame, borderwidth=1, text="Credentials" + ) + self.settings_frame = ConfigFrame( + self, self.scrollable_frame, borderwidth=1, text="Config" + ) + self.progress_frame = ProgressFrame( + self, self.scrollable_frame, borderwidth=1, text="Progress" + ) self.control_frame = ControlFrame(self, self.scrollable_frame, borderwidth=1) - + def pack_frames(self): - self.input_frame.grid(column=0, row=0, sticky='w', padx=5, pady=5) - self.comp_frame.grid(column=1, row=0, sticky='news', padx=5, pady=5) - self.output_frame.grid(column=0, row=1, sticky='w', padx=5, pady=5) - self.cred_frame.grid(column=1, row=1, rowspan=2, sticky='w', padx=5, pady=5) - self.settings_frame.grid(column=0, row=2, sticky='news', padx=5, pady=5) - self.progress_frame.grid(column=0, row=3, columnspan=2, sticky='news', padx=5, pady=5) - self.control_frame.grid(column=0, row=4, columnspan=2, sticky='news', padx=5, pady=5) - + self.input_frame.grid(column=0, row=0, sticky="w", padx=5, pady=5) + self.comp_frame.grid(column=1, row=0, sticky="news", padx=5, pady=5) + self.output_frame.grid(column=0, row=1, sticky="w", padx=5, pady=5) + self.cred_frame.grid(column=1, row=1, rowspan=2, sticky="w", padx=5, pady=5) + self.settings_frame.grid(column=0, row=2, sticky="news", padx=5, pady=5) + self.progress_frame.grid( + column=0, row=3, columnspan=2, sticky="news", padx=5, pady=5 + ) + self.control_frame.grid( + column=0, row=4, columnspan=2, sticky="news", padx=5, pady=5 + ) + def warn_tkinter_bug(self): - if (platform.system() == 'Darwin' and - platform.mac_ver()[0].split('.')[0] == '14' and - sys.version_info[0] == 3 and - sys.version_info[1] == 11 and - sys.version_info[2] <= 6): - msg = 'NOTICE: If buttons are not responsive, try to press ' - msg += 'on title bar or move mouse cursor away from window for a while.' + if ( + platform.system() == "Darwin" + and platform.mac_ver()[0].split(".")[0] == "14" + and sys.version_info[0] == 3 + and sys.version_info[1] == 11 + and sys.version_info[2] <= 6 + ): + msg = "NOTICE: If buttons are not responsive, try to press " + msg += "on title bar or move mouse cursor away from window for a while." self.cb_msg(msg) - msg = '(This is due to a bug in tkinter specific to macOS 14 python <=3.11.6)' + msg = ( + "(This is due to a bug in tkinter specific to macOS 14 python <=3.11.6)" + ) self.cb_msg(msg) - msg = '(https://github.com/python/cpython/issues/110218)' + msg = "(https://github.com/python/cpython/issues/110218)" self.cb_msg(msg) - + def load_jsons(self): - self.help = JsonManager.load_json(ROOT_DIR / 'resources/help.json') - self.input_presets = JsonManager.load_json(ROOT_DIR / 'resources/input.json') - self.compression_presets = JsonManager.load_json(ROOT_DIR / 'resources/compression.json') - self.output_presets = JsonManager.load_json(ROOT_DIR / 'resources/output.json') - self.emoji_list = JsonManager.load_json(ROOT_DIR / 'resources/emoji.json') - - if not (self.compression_presets and self.input_presets and self.output_presets): - Messagebox.show_error(message='Warning: json(s) under "resources" directory cannot be found', title='sticker-convert') + self.help = JsonManager.load_json(ROOT_DIR / "resources/help.json") + self.input_presets = JsonManager.load_json(ROOT_DIR / "resources/input.json") + self.compression_presets = JsonManager.load_json( + ROOT_DIR / "resources/compression.json" + ) + self.output_presets = JsonManager.load_json(ROOT_DIR / "resources/output.json") + self.emoji_list = JsonManager.load_json(ROOT_DIR / "resources/emoji.json") + + if not ( + self.compression_presets and self.input_presets and self.output_presets + ): + Messagebox.show_error( # type: ignore + message='Warning: json(s) under "resources" directory cannot be found', + title="sticker-convert", + ) sys.exit() - self.settings_path = CONFIG_DIR / 'config.json' + self.settings_path = CONFIG_DIR / "config.json" if self.settings_path.is_file(): - self.settings = JsonManager.load_json(self.settings_path) + try: + self.settings: dict[Any, Any] = JsonManager.load_json( + self.settings_path + ) + except JSONDecodeError: + self.cb_msg("Warning: config.json content is corrupted") + self.settings = {} else: self.settings = {} - - self.creds_path = CONFIG_DIR / 'creds.json' + + self.creds_path = CONFIG_DIR / "creds.json" if self.creds_path.is_file(): - self.creds = JsonManager.load_json(self.creds_path) + try: + self.creds = JsonManager.load_json(self.creds_path) + except JSONDecodeError: + self.cb_msg("Warning: creds.json content is corrupted") + self.creds = {} else: self.creds = {} - + def save_config(self): # Only update comp_custom if custom preset is selected - if self.comp_preset_var.get() == 'custom': - comp_custom = self.get_opt_comp() - del comp_custom['preset'] - del comp_custom['no_compress'] + if self.comp_preset_var.get() == "custom": + comp_custom = self.get_opt_comp().to_dict() + del comp_custom["preset"] + del comp_custom["no_compress"] else: - comp_custom = self.compression_presets.get('custom') + comp_custom = self.compression_presets.get("custom") self.settings = { - 'input': self.get_opt_input(), - 'comp': { - 'no_compress': self.no_compress_var.get(), - 'preset': self.comp_preset_var.get(), - 'cache_dir': self.cache_dir_var.get(), - 'processes': self.processes_var.get() + "input": self.get_opt_input().to_dict(), + "comp": { + "no_compress": self.no_compress_var.get(), + "preset": self.comp_preset_var.get(), + "cache_dir": self.cache_dir_var.get(), + "processes": self.processes_var.get(), }, - 'comp_custom': comp_custom, - 'output': self.get_opt_output(), - 'creds': { - 'save_cred': self.settings_save_cred_var.get() - } + "comp_custom": comp_custom, + "output": self.get_opt_output().to_dict(), + "creds": {"save_cred": self.settings_save_cred_var.get()}, } JsonManager.save_json(self.settings_path, self.settings) - + def save_creds(self): - self.creds = self.get_opt_cred() + self.creds = self.get_opt_cred().to_dict() JsonManager.save_json(self.creds_path, self.creds) - + def delete_creds(self): if self.creds_path.is_file(): os.remove(self.creds_path) - + def delete_config(self): if self.settings_path.is_file(): os.remove(self.settings_path) - + def apply_config(self): # Input - self.default_input_mode = self.settings.get('input', {}).get('option', 'auto') - self.input_address_var.set(self.settings.get('input', {}).get('url', '')) - default_stickers_input_dir = DEFAULT_DIR / 'stickers_input' - self.input_setdir_var.set(self.settings.get('input', {}).get('dir', default_stickers_input_dir)) + self.default_input_mode: str = self.settings.get("input", {}).get( + "option", "auto" + ) + self.input_address_var.set(self.settings.get("input", {}).get("url", "")) + default_stickers_input_dir = str(DEFAULT_DIR / "stickers_input") + self.input_setdir_var.set( + self.settings.get("input", {}).get("dir", default_stickers_input_dir) + ) if not Path(self.input_setdir_var.get()).is_dir(): self.input_setdir_var.set(default_stickers_input_dir) - self.input_option_display_var.set(self.input_presets[self.default_input_mode]['full_name']) - self.input_option_true_var.set(self.input_presets[self.default_input_mode]['full_name']) + self.input_option_display_var.set( + self.input_presets[self.default_input_mode]["full_name"] + ) + self.input_option_true_var.set( + self.input_presets[self.default_input_mode]["full_name"] + ) # Compression - self.no_compress_var.set(self.settings.get('comp', {}).get('no_compress', False)) + self.no_compress_var.set( + self.settings.get("comp", {}).get("no_compress", False) + ) default_comp_preset = list(self.compression_presets.keys())[0] - self.comp_preset_var.set(self.settings.get('comp', {}).get('preset', default_comp_preset)) - if self.settings.get('comp_custom'): - self.compression_presets['custom'] = self.settings.get('comp_custom') - self.cache_dir_var.set(self.settings.get('comp', {}).get('cache_dir', '')) - self.processes_var.set(self.settings.get('comp', {}).get('processes', math.ceil(cpu_count() / 2))) - self.default_output_mode = self.settings.get('output', {}).get('option', 'signal') + self.comp_preset_var.set( + self.settings.get("comp", {}).get("preset", default_comp_preset) + ) + if self.settings.get("comp_custom"): + self.compression_presets["custom"] = self.settings.get("comp_custom") + self.cache_dir_var.set(self.settings.get("comp", {}).get("cache_dir", "")) + self.processes_var.set( + self.settings.get("comp", {}).get("processes", math.ceil(cpu_count() / 2)) + ) + self.default_output_mode: str = self.settings.get("output", {}).get( + "option", "signal" + ) # Output - default_stickers_output_dir = DEFAULT_DIR / 'stickers_output' - self.output_setdir_var.set(self.settings.get('output', {}).get('dir', default_stickers_output_dir)) + default_stickers_output_dir = str(DEFAULT_DIR / "stickers_output") + self.output_setdir_var.set( + self.settings.get("output", {}).get("dir", default_stickers_output_dir) + ) if not Path(self.output_setdir_var.get()).is_dir(): self.output_setdir_var.set(default_stickers_output_dir) - self.title_var.set(self.settings.get('output', {}).get('title', '')) - self.author_var.set(self.settings.get('output', {}).get('author', '')) - self.settings_save_cred_var.set(self.settings.get('creds', {}).get('save_cred', True)) - self.output_option_display_var.set(self.output_presets[self.default_output_mode]['full_name']) - self.output_option_true_var.set(self.output_presets[self.default_output_mode]['full_name']) - + self.title_var.set(self.settings.get("output", {}).get("title", "")) + self.author_var.set(self.settings.get("output", {}).get("author", "")) + self.settings_save_cred_var.set( + self.settings.get("creds", {}).get("save_cred", True) + ) + self.output_option_display_var.set( + self.output_presets[self.default_output_mode]["full_name"] + ) + self.output_option_true_var.set( + self.output_presets[self.default_output_mode]["full_name"] + ) + def apply_creds(self): - self.signal_uuid_var.set(self.creds.get('signal', {}).get('uuid', '')) - self.signal_password_var.set(self.creds.get('signal', {}).get('password', '')) - self.telegram_token_var.set(self.creds.get('telegram', {}).get('token', '')) - self.telegram_userid_var.set(self.creds.get('telegram', {}).get('userid', '')) - self.kakao_auth_token_var.set(self.creds.get('kakao', {}).get('auth_token', '')) - self.kakao_username_var.set(self.creds.get('kakao', {}).get('username', '')) - self.kakao_password_var.set(self.creds.get('kakao', {}).get('password', '')) - self.kakao_country_code_var.set(self.creds.get('kakao', {}).get('country_code', '')) - self.kakao_phone_number_var.set(self.creds.get('kakao', {}).get('phone_number', '')) - self.line_cookies_var.set(self.creds.get('line', {}). get('cookies', '')) - + self.signal_uuid_var.set(self.creds.get("signal", {}).get("uuid", "")) + self.signal_password_var.set(self.creds.get("signal", {}).get("password", "")) + self.telegram_token_var.set(self.creds.get("telegram", {}).get("token", "")) + self.telegram_userid_var.set(self.creds.get("telegram", {}).get("userid", "")) + self.kakao_auth_token_var.set(self.creds.get("kakao", {}).get("auth_token", "")) + self.kakao_username_var.set(self.creds.get("kakao", {}).get("username", "")) + self.kakao_password_var.set(self.creds.get("kakao", {}).get("password", "")) + self.kakao_country_code_var.set( + self.creds.get("kakao", {}).get("country_code", "") + ) + self.kakao_phone_number_var.set( + self.creds.get("kakao", {}).get("phone_number", "") + ) + self.line_cookies_var.set(self.creds.get("line", {}).get("cookies", "")) + def get_input_name(self) -> str: - return [k for k, v in self.input_presets.items() if v['full_name'] == self.input_option_true_var.get()][0] + return [ + k + for k, v in self.input_presets.items() + if v["full_name"] == self.input_option_true_var.get() + ][0] def get_input_display_name(self) -> str: - return [k for k, v in self.input_presets.items() if v['full_name'] == self.input_option_display_var.get()][0] + return [ + k + for k, v in self.input_presets.items() + if v["full_name"] == self.input_option_display_var.get() + ][0] def get_output_name(self) -> str: - return [k for k, v in self.output_presets.items() if v['full_name'] == self.output_option_true_var.get()][0] + return [ + k + for k, v in self.output_presets.items() + if v["full_name"] == self.output_option_true_var.get() + ][0] def get_output_display_name(self) -> str: - return [k for k, v in self.output_presets.items() if v['full_name'] == self.output_option_display_var.get()][0] + return [ + k + for k, v in self.output_presets.items() + if v["full_name"] == self.output_option_display_var.get() + ][0] def get_preset(self) -> str: selection = self.comp_preset_var.get() - if selection == 'auto': + if selection == "auto": output_option = self.get_output_name() - if output_option == 'imessage': - return 'imessage_small' - elif output_option == 'local': + if output_option == "imessage": + return "imessage_small" + elif output_option == "local": return selection else: return output_option - + else: return selection - + def start_job(self): self.save_config() - if self.settings_save_cred_var.get() == True: + if self.settings_save_cred_var.get() is True: self.save_creds() else: self.delete_creds() - self.control_frame.start_btn.config(text='Cancel', bootstyle='danger') - self.set_inputs('disabled') - - opt_input = InputOption(self.get_opt_input()) - opt_output = OutputOption(self.get_opt_output()) - opt_comp = CompOption(self.get_opt_comp()) - opt_cred = CredOption(self.get_opt_cred()) - + self.control_frame.start_btn.config(text="Cancel", bootstyle="danger") # type: ignore + self.set_inputs("disabled") + + opt_input = self.get_opt_input() + opt_output = self.get_opt_output() + opt_comp = self.get_opt_comp() + opt_cred = self.get_opt_cred() + self.job = Job( - opt_input, opt_comp, opt_output, opt_cred, - self.cb_msg, self.cb_msg_block, self.cb_bar, self.cb_ask_bool, self.cb_ask_str - ) - + opt_input, + opt_comp, + opt_output, + opt_cred, + self.cb_msg, + self.cb_msg_block, + self.cb_bar, + self.cb_ask_bool, + self.cb_ask_str, + ) + signal.signal(signal.SIGINT, self.job.cancel) - + Thread(target=self.start_process, daemon=True).start() - - def get_opt_input(self) -> dict: - return { - 'option': self.get_input_name(), - 'url': self.input_address_var.get(), - 'dir': self.input_setdir_var.get() - } - - def get_opt_output(self) -> dict: - return { - 'option': self.get_output_name(), - 'dir': self.output_setdir_var.get(), - 'title': self.title_var.get(), - 'author': self.author_var.get() - } - - def get_opt_comp(self) -> dict: - return { - 'preset': self.get_preset(), - 'size_max': { - 'img': self.img_size_max_var.get() if not self.size_disable_var.get() else None, - 'vid': self.vid_size_max_var.get() if not self.size_disable_var.get() else None - }, - 'format': { - 'img': self.img_format_var.get(), - 'vid': self.vid_format_var.get() - }, - 'fps': { - 'min': self.fps_min_var.get() if not self.fps_disable_var.get() else None, - 'max': self.fps_max_var.get() if not self.fps_disable_var.get() else None, - 'power': self.fps_power_var.get() - }, - 'res': { - 'w': { - 'min': self.res_w_min_var.get() if not self.res_w_disable_var.get() else None, - 'max': self.res_w_max_var.get() if not self.res_w_disable_var.get() else None - }, - 'h': { - 'min': self.res_h_min_var.get() if not self.res_h_disable_var.get() else None, - 'max': self.res_h_max_var.get() if not self.res_h_disable_var.get() else None - }, - 'power': self.res_power_var.get() - }, - 'quality': { - 'min': self.quality_min_var.get() if not self.quality_disable_var.get() else None, - 'max': self.quality_max_var.get() if not self.quality_disable_var.get() else None, - 'power': self.quality_power_var.get() - }, - 'color': { - 'min': self.color_min_var.get() if not self.color_disable_var.get() else None, - 'max': self.color_max_var.get() if not self.color_disable_var.get() else None, - 'power': self.color_power_var.get() - }, - 'duration': { - 'min': self.duration_min_var.get() if not self.duration_disable_var.get() else None, - 'max': self.duration_max_var.get() if not self.duration_disable_var.get() else None - }, - 'steps': self.steps_var.get(), - 'fake_vid': self.fake_vid_var.get(), - 'scale_filter': self.scale_filter_var.get(), - 'quantize_method': self.quantize_method_var.get(), - 'cache_dir': self.cache_dir_var.get() if self.cache_dir_var.get() != '' else None, - 'default_emoji': self.default_emoji_var.get(), - 'no_compress': self.no_compress_var.get(), - 'processes': self.processes_var.get() - } - - def get_opt_cred(self) -> dict: - return { - 'signal': { - 'uuid': self.signal_uuid_var.get(), - 'password': self.signal_password_var.get() - }, - 'telegram': { - 'token': self.telegram_token_var.get(), - 'userid': self.telegram_userid_var.get() - }, - 'kakao': { - 'auth_token': self.kakao_auth_token_var.get(), - 'username': self.kakao_username_var.get(), - 'password': self.kakao_password_var.get(), - 'country_code': self.kakao_country_code_var.get(), - 'phone_number': self.kakao_phone_number_var.get() - }, - 'line': { - 'cookies': self.line_cookies_var.get() - } - } + + def get_opt_input(self) -> InputOption: + return InputOption( + option=self.get_input_name(), + url=self.input_address_var.get(), + dir=Path(self.input_setdir_var.get()), + ) + + def get_opt_output(self) -> OutputOption: + return OutputOption( + option=self.get_output_name(), + dir=Path(self.output_setdir_var.get()), + title=self.title_var.get(), + author=self.author_var.get(), + ) + + def get_opt_comp(self) -> CompOption: + return CompOption( + preset=self.get_preset(), + size_max_img=self.img_size_max_var.get() + if not self.size_disable_var.get() + else None, + size_max_vid=self.vid_size_max_var.get() + if not self.size_disable_var.get() + else None, + format_img=[self.img_format_var.get()], + format_vid=[self.vid_format_var.get()], + fps_min=self.fps_min_var.get() if not self.fps_disable_var.get() else None, + fps_max=self.fps_max_var.get() if not self.fps_disable_var.get() else None, + fps_power=self.fps_power_var.get(), + res_w_min=self.res_w_min_var.get() + if not self.res_w_disable_var.get() + else None, + res_w_max=self.res_w_max_var.get() + if not self.res_w_disable_var.get() + else None, + res_h_min=self.res_h_min_var.get() + if not self.res_h_disable_var.get() + else None, + res_h_max=self.res_h_max_var.get() + if not self.res_h_disable_var.get() + else None, + res_power=self.res_power_var.get(), + quality_min=self.quality_min_var.get() + if not self.quality_disable_var.get() + else None, + quality_max=self.quality_max_var.get() + if not self.quality_disable_var.get() + else None, + quality_power=self.quality_power_var.get(), + color_min=self.color_min_var.get() + if not self.color_disable_var.get() + else None, + color_max=self.color_max_var.get() + if not self.color_disable_var.get() + else None, + color_power=self.color_power_var.get(), + duration_min=self.duration_min_var.get() + if not self.duration_disable_var.get() + else None, + duration_max=self.duration_max_var.get() + if not self.duration_disable_var.get() + else None, + steps=self.steps_var.get(), + fake_vid=self.fake_vid_var.get(), + scale_filter=self.scale_filter_var.get(), + quantize_method=self.quantize_method_var.get(), + cache_dir=self.cache_dir_var.get() + if self.cache_dir_var.get() != "" + else None, + default_emoji=self.default_emoji_var.get(), + no_compress=self.no_compress_var.get(), + processes=self.processes_var.get(), + ) + + def get_opt_cred(self) -> CredOption: + return CredOption( + signal_uuid=self.signal_uuid_var.get(), + signal_password=self.signal_password_var.get(), + telegram_token=self.telegram_token_var.get(), + telegram_userid=self.telegram_userid_var.get(), + kakao_auth_token=self.kakao_auth_token_var.get(), + kakao_username=self.kakao_username_var.get(), + kakao_password=self.kakao_password_var.get(), + kakao_country_code=self.kakao_country_code_var.get(), + kakao_phone_number=self.kakao_phone_number_var.get(), + line_cookies=self.line_cookies_var.get(), + ) def start_process(self): - status = self.job.start() + if self.job: + self.job.start() self.job = None self.stop_job() - + def stop_job(self): - self.set_inputs('normal') - self.control_frame.start_btn.config(text='Start', bootstyle='default') - + self.set_inputs("normal") + self.control_frame.start_btn.config(text="Start", bootstyle="default") # type: ignore + def cancel_job(self): - self.cb_msg(msg='Cancelling job...') - self.job.cancel() + if self.job: + self.cb_msg(msg="Cancelling job...") + self.job.cancel() def set_inputs(self, state: str): # state: 'normal', 'disabled' @@ -468,171 +554,220 @@ def set_inputs(self, state: str): self.cred_frame.set_states(state=state) self.settings_frame.set_states(state=state) - if state == 'normal': + if state == "normal": self.input_frame.cb_input_option() self.comp_frame.cb_no_compress() - - def exec_in_main(self, evt) -> Any: - self.response = self.action() + + def exec_in_main(self, evt: Any) -> Any: + if self.action: + self.response = self.action() self.response_event.set() - - def cb_ask_str(self, - question: str, - initialvalue: Optional[str] = None, - cli_show_initialvalue: bool = True, - parent: Optional[object] = None) -> str: - self.action = partial(Querybox.get_string, question, title='sticker-convert', initialvalue=initialvalue, parent=parent) + + def cb_ask_str( + self, + question: str, + initialvalue: Optional[str] = None, + cli_show_initialvalue: bool = True, + parent: Optional[object] = None, + ) -> Any: + self.action = partial( + Querybox.get_string, # type: ignore + question, + title="sticker-convert", + initialvalue=initialvalue, + parent=parent, + ) self.event_generate("<>") self.response_event.wait() self.response_event.clear() return self.response - def cb_ask_bool(self, question, parent=None) -> bool: - self.action = partial(Messagebox.yesno, question, title='sticker-convert', parent=parent) + def cb_ask_bool( + self, question: str, parent: Union[Window, Toplevel, None] = None + ) -> bool: + self.action = partial( + Messagebox.yesno, # type: ignore + question, + title="sticker-convert", + parent=parent, + ) self.event_generate("<>") self.response_event.wait() self.response_event.clear() - if self.response == 'Yes': + if self.response == "Yes": return True return False - def cb_msg(self, *args, **kwargs): + def cb_msg(self, *args: Any, **kwargs: Any): with self.msg_lock: self.progress_frame.update_message_box(*args, **kwargs) - - def cb_msg_block(self, - message: Optional[str] = None, - parent: Optional[object] = None, - *args, **kwargs): - if message == None and len(args) > 0: - message = ' '.join(str(i) for i in args) - self.action = partial(Messagebox.show_info, message, title='sticker-convert', parent=parent) + + def cb_msg_block( + self, + message: Optional[str] = None, + parent: Optional[object] = None, + *args: Any, + **kwargs: Any, + ) -> Any: + if message is None and len(args) > 0: + message = " ".join(str(i) for i in args) + self.action = partial( + Messagebox.show_info, # type: ignore + message, + title="sticker-convert", + parent=parent, + ) self.event_generate("<>") self.response_event.wait() self.response_event.clear() return self.response - - def cb_bar(self, *args, **kwargs): + + def cb_bar(self, *args: Any, **kwargs: Any): with self.bar_lock: self.progress_frame.update_progress_bar(*args, **kwargs) - + def highlight_fields(self) -> bool: if not self.init_done: return True - + input_option = self.get_input_name() input_option_display = self.get_input_display_name() output_option = self.get_output_name() # output_option_display = self.get_output_display_name() url = self.input_address_var.get() - if Path(self.input_setdir_var.get()).absolute() == Path(self.output_setdir_var.get()).absolute(): + if ( + Path(self.input_setdir_var.get()).absolute() + == Path(self.output_setdir_var.get()).absolute() + ): in_out_dir_same = True else: in_out_dir_same = False # Input - if in_out_dir_same == True: - self.input_frame.input_setdir_entry.config(bootstyle='danger') + if in_out_dir_same is True: + self.input_frame.input_setdir_entry.config(bootstyle="danger") # type: ignore elif not Path(self.input_setdir_var.get()).is_dir(): - self.input_frame.input_setdir_entry.config(bootstyle='warning') + self.input_frame.input_setdir_entry.config(bootstyle="warning") # type: ignore else: - self.input_frame.input_setdir_entry.config(bootstyle='default') + self.input_frame.input_setdir_entry.config(bootstyle="default") # type: ignore - self.input_frame.address_lbl.config(text=self.input_presets[input_option_display]['address_lbls']) - self.input_frame.address_entry.config(bootstyle='default') + self.input_frame.address_lbl.config( + text=self.input_presets[input_option_display]["address_lbls"] + ) + self.input_frame.address_entry.config(bootstyle="default") # type: ignore - if input_option == 'local': - self.input_frame.address_entry.config(state='disabled') - self.input_frame.address_tip.config(text=self.input_presets[input_option_display]['example']) + if input_option == "local": + self.input_frame.address_entry.config(state="disabled") + self.input_frame.address_tip.config( + text=self.input_presets[input_option_display]["example"] + ) else: - self.input_frame.address_entry.config(state='normal') - self.input_frame.address_tip.config(text=self.input_presets[input_option_display]['example']) + self.input_frame.address_entry.config(state="normal") + self.input_frame.address_tip.config( + text=self.input_presets[input_option_display]["example"] + ) download_option = UrlDetect.detect(url) if not url: - self.input_frame.address_entry.config(bootstyle='warning') + self.input_frame.address_entry.config(bootstyle="warning") # type: ignore + + elif download_option != input_option and not ( + input_option in ("kakao", "line") and url.isnumeric() + ): + self.input_frame.address_entry.config(bootstyle="danger") # type: ignore + self.input_frame.address_tip.config( + text=f"Invalid URL. {self.input_presets[input_option_display]['example']}" + ) - elif (download_option != input_option and - not (input_option in ('kakao', 'line') and url.isnumeric())): - - self.input_frame.address_entry.config(bootstyle='danger') - self.input_frame.address_tip.config(text=f"Invalid URL. {self.input_presets[input_option_display]['example']}") - - elif input_option_display == 'auto' and download_option: - self.input_frame.address_tip.config(text=f'Detected URL: {download_option}') + elif input_option_display == "auto" and download_option: + self.input_frame.address_tip.config( + text=f"Detected URL: {download_option}" + ) # Output - if in_out_dir_same == True: - self.output_frame.output_setdir_entry.config(bootstyle='danger') + if in_out_dir_same is True: + self.output_frame.output_setdir_entry.config(bootstyle="danger") # type: ignore elif not Path(self.output_setdir_var.get()).is_dir(): - self.output_frame.output_setdir_entry.config(bootstyle='warning') + self.output_frame.output_setdir_entry.config(bootstyle="warning") # type: ignore else: - self.output_frame.output_setdir_entry.config(bootstyle='default') + self.output_frame.output_setdir_entry.config(bootstyle="default") # type: ignore - if (MetadataHandler.check_metadata_required(output_option, 'title') and - not MetadataHandler.check_metadata_provided(Path(self.input_setdir_var.get()), input_option, 'title') and - not self.title_var.get()): - - self.output_frame.title_entry.config(bootstyle='warning') + if ( + MetadataHandler.check_metadata_required(output_option, "title") + and not MetadataHandler.check_metadata_provided( + Path(self.input_setdir_var.get()), input_option, "title" + ) + and not self.title_var.get() + ): + self.output_frame.title_entry.config(bootstyle="warning") # type: ignore else: - self.output_frame.title_entry.config(bootstyle='default') + self.output_frame.title_entry.config(bootstyle="default") # type: ignore - if (MetadataHandler.check_metadata_required(output_option, 'author') and - not MetadataHandler.check_metadata_provided(Path(self.input_setdir_var.get()), input_option, 'author') and - not self.author_var.get()): - - self.output_frame.author_entry.config(bootstyle='warning') + if ( + MetadataHandler.check_metadata_required(output_option, "author") + and not MetadataHandler.check_metadata_provided( + Path(self.input_setdir_var.get()), input_option, "author" + ) + and not self.author_var.get() + ): + self.output_frame.author_entry.config(bootstyle="warning") # type: ignore else: - self.output_frame.author_entry.config(bootstyle='default') - - if self.comp_preset_var.get() == 'auto': - if output_option == 'local': + self.output_frame.author_entry.config(bootstyle="default") # type: ignore + + if self.comp_preset_var.get() == "auto": + if output_option == "local": self.no_compress_var.set(True) else: self.no_compress_var.set(False) self.comp_frame.cb_no_compress() - + # Credentials - if output_option == 'signal' and not self.signal_uuid_var.get(): - self.cred_frame.signal_uuid_entry.config(bootstyle='warning') + if output_option == "signal" and not self.signal_uuid_var.get(): + self.cred_frame.signal_uuid_entry.config(bootstyle="warning") # type: ignore else: - self.cred_frame.signal_uuid_entry.config(bootstyle='default') + self.cred_frame.signal_uuid_entry.config(bootstyle="default") # type: ignore - if output_option == 'signal' and not self.signal_password_var.get(): - self.cred_frame.signal_password_entry.config(bootstyle='warning') + if output_option == "signal" and not self.signal_password_var.get(): + self.cred_frame.signal_password_entry.config(bootstyle="warning") # type: ignore else: - self.cred_frame.signal_password_entry.config(bootstyle='default') + self.cred_frame.signal_password_entry.config(bootstyle="default") # type: ignore - if (input_option == 'telegram' or output_option == 'telegram') and not self.telegram_token_var.get(): - self.cred_frame.telegram_token_entry.config(bootstyle='warning') + if ( + input_option == "telegram" or output_option == "telegram" + ) and not self.telegram_token_var.get(): + self.cred_frame.telegram_token_entry.config(bootstyle="warning") # type: ignore else: - self.cred_frame.telegram_token_entry.config(bootstyle='default') + self.cred_frame.telegram_token_entry.config(bootstyle="default") # type: ignore - if output_option == 'telegram' and not self.telegram_userid_var.get(): - self.cred_frame.telegram_userid_entry.config(bootstyle='warning') + if output_option == "telegram" and not self.telegram_userid_var.get(): + self.cred_frame.telegram_userid_entry.config(bootstyle="warning") # type: ignore else: - self.cred_frame.telegram_userid_entry.config(bootstyle='default') - - if urlparse(url).netloc == 'e.kakao.com' and not self.kakao_auth_token_var.get(): - self.cred_frame.kakao_auth_token_entry.config(bootstyle='warning') + self.cred_frame.telegram_userid_entry.config(bootstyle="default") # type: ignore + + if ( + urlparse(url).netloc == "e.kakao.com" + and not self.kakao_auth_token_var.get() + ): + self.cred_frame.kakao_auth_token_entry.config(bootstyle="warning") # type: ignore else: - self.cred_frame.kakao_auth_token_entry.config(bootstyle='default') - - # Check for Input and Compression mismatch - if (not self.no_compress_var.get() and - self.get_output_name() != 'local' and - self.comp_preset_var.get() not in ('auto', 'custom') and - self.get_output_name() not in self.comp_preset_var.get()): + self.cred_frame.kakao_auth_token_entry.config(bootstyle="default") # type: ignore - self.comp_frame.comp_preset_opt.config(bootstyle='warning') - self.output_frame.output_option_opt.config(bootstyle='warning') + # Check for Input and Compression mismatch + if ( + not self.no_compress_var.get() + and self.get_output_name() != "local" + and self.comp_preset_var.get() not in ("auto", "custom") + and self.get_output_name() not in self.comp_preset_var.get() + ): + self.comp_frame.comp_preset_opt.config(bootstyle="warning") # type: ignore + self.output_frame.output_option_opt.config(bootstyle="warning") # type: ignore else: - self.comp_frame.comp_preset_opt.config(bootstyle='secondary') - self.output_frame.output_option_opt.config(bootstyle='secondary') - - return True \ No newline at end of file + self.comp_frame.comp_preset_opt.config(bootstyle="secondary") # type: ignore + self.output_frame.output_option_opt.config(bootstyle="secondary") # type: ignore + + return True diff --git a/src/sticker_convert/gui_components/__init__.py b/src/sticker_convert/gui_components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sticker_convert/gui_components/frames/__init__.py b/src/sticker_convert/gui_components/frames/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sticker_convert/gui_components/frames/comp_frame.py b/src/sticker_convert/gui_components/frames/comp_frame.py index 1c5aca6..62c563e 100644 --- a/src/sticker_convert/gui_components/frames/comp_frame.py +++ b/src/sticker_convert/gui_components/frames/comp_frame.py @@ -1,121 +1,207 @@ #!/usr/bin/env python3 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from ttkbootstrap import (Button, Checkbutton, Entry, Label, # type: ignore - LabelFrame, OptionMenu) +from ttkbootstrap import Button, Checkbutton, Entry, Label, LabelFrame, OptionMenu # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore -from sticker_convert.gui_components.windows.advanced_compression_window import AdvancedCompressionWindow # type: ignore +from sticker_convert.gui_components.frames.right_clicker import RightClicker +from sticker_convert.gui_components.windows.advanced_compression_window import ( + AdvancedCompressionWindow, +) class CompFrame(LabelFrame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(CompFrame, self).__init__(*args, **kwargs) - - self.grid_columnconfigure(2, weight = 1) - self.no_compress_help_btn = Button(self, text='?', width=1, command=lambda: self.gui.cb_msg_block(self.gui.help['comp']['no_compress']), bootstyle='secondary') - self.no_compress_lbl = Label(self, text='No compression') - self.no_compress_cbox = Checkbutton(self, variable=self.gui.no_compress_var, command=self.cb_no_compress, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.comp_preset_help_btn = Button(self, text='?', width=1, command=lambda: self.gui.cb_msg_block(self.gui.help['comp']['preset']), bootstyle='secondary') - self.comp_preset_lbl = Label(self, text='Preset') - self.comp_preset_opt = OptionMenu(self, self.gui.comp_preset_var, self.gui.comp_preset_var.get(), *self.gui.compression_presets.keys(), command=self.cb_comp_apply_preset, bootstyle='secondary') + self.grid_columnconfigure(2, weight=1) + + self.no_compress_help_btn = Button( + self, + text="?", + width=1, + command=lambda: self.gui.cb_msg_block(self.gui.help["comp"]["no_compress"]), + bootstyle="secondary", # type: ignore + ) + self.no_compress_lbl = Label(self, text="No compression") + self.no_compress_cbox = Checkbutton( + self, + variable=self.gui.no_compress_var, + command=self.cb_no_compress, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.comp_preset_help_btn = Button( + self, + text="?", + width=1, + command=lambda: self.gui.cb_msg_block(self.gui.help["comp"]["preset"]), + bootstyle="secondary", # type: ignore + ) + self.comp_preset_lbl = Label(self, text="Preset") + self.comp_preset_opt = OptionMenu( + self, + self.gui.comp_preset_var, + self.gui.comp_preset_var.get(), + *self.gui.compression_presets.keys(), + command=self.cb_comp_apply_preset, + bootstyle="secondary", # type: ignore + ) self.comp_preset_opt.config(width=15) - self.steps_help_btn = Button(self, text='?', width=1, command=lambda: self.gui.cb_msg_block(self.gui.help['comp']['steps']), bootstyle='secondary') - self.steps_lbl = Label(self, text='Number of steps') + self.steps_help_btn = Button( + self, + text="?", + width=1, + command=lambda: self.gui.cb_msg_block(self.gui.help["comp"]["steps"]), + bootstyle="secondary", # type: ignore + ) + self.steps_lbl = Label(self, text="Number of steps") self.steps_entry = Entry(self, textvariable=self.gui.steps_var, width=8) - self.steps_entry.bind('', RightClicker) - - self.processes_help_btn = Button(self, text='?', width=1, command=lambda: self.gui.cb_msg_block(self.gui.help['comp']['processes']), bootstyle='secondary') - self.processes_lbl = Label(self, text='Number of processes') + self.steps_entry.bind("", RightClicker) + + self.processes_help_btn = Button( + self, + text="?", + width=1, + command=lambda: self.gui.cb_msg_block(self.gui.help["comp"]["processes"]), + bootstyle="secondary", # type: ignore + ) + self.processes_lbl = Label(self, text="Number of processes") self.processes_entry = Entry(self, textvariable=self.gui.processes_var, width=8) - self.processes_entry.bind('', RightClicker) + self.processes_entry.bind("", RightClicker) - self.comp_advanced_btn = Button(self, text='Advanced...', command=self.cb_compress_advanced, bootstyle='secondary') + self.comp_advanced_btn = Button( + self, + text="Advanced...", + command=self.cb_compress_advanced, + bootstyle="secondary", # type: ignore + ) - self.no_compress_help_btn.grid(column=0, row=0, sticky='w', padx=3, pady=3) - self.no_compress_lbl.grid(column=1, row=0, sticky='w', padx=3, pady=3) - self.no_compress_cbox.grid(column=2, row=0, sticky='nes', padx=3, pady=3) + self.no_compress_help_btn.grid(column=0, row=0, sticky="w", padx=3, pady=3) + self.no_compress_lbl.grid(column=1, row=0, sticky="w", padx=3, pady=3) + self.no_compress_cbox.grid(column=2, row=0, sticky="nes", padx=3, pady=3) - self.comp_preset_help_btn.grid(column=0, row=1, sticky='w', padx=3, pady=3) - self.comp_preset_lbl.grid(column=1, row=1, sticky='w', padx=3, pady=3) - self.comp_preset_opt.grid(column=2, row=1, sticky='nes', padx=3, pady=3) + self.comp_preset_help_btn.grid(column=0, row=1, sticky="w", padx=3, pady=3) + self.comp_preset_lbl.grid(column=1, row=1, sticky="w", padx=3, pady=3) + self.comp_preset_opt.grid(column=2, row=1, sticky="nes", padx=3, pady=3) - self.steps_help_btn.grid(column=0, row=2, sticky='w', padx=3, pady=3) - self.steps_lbl.grid(column=1, row=2, sticky='w', padx=3, pady=3) - self.steps_entry.grid(column=2, row=2, sticky='nes', padx=3, pady=3) + self.steps_help_btn.grid(column=0, row=2, sticky="w", padx=3, pady=3) + self.steps_lbl.grid(column=1, row=2, sticky="w", padx=3, pady=3) + self.steps_entry.grid(column=2, row=2, sticky="nes", padx=3, pady=3) - self.processes_help_btn.grid(column=0, row=3, sticky='w', padx=3, pady=3) - self.processes_lbl.grid(column=1, row=3, sticky='w', padx=3, pady=3) - self.processes_entry.grid(column=2, row=3, sticky='nes', padx=3, pady=3) + self.processes_help_btn.grid(column=0, row=3, sticky="w", padx=3, pady=3) + self.processes_lbl.grid(column=1, row=3, sticky="w", padx=3, pady=3) + self.processes_entry.grid(column=2, row=3, sticky="nes", padx=3, pady=3) - self.comp_advanced_btn.grid(column=2, row=4, sticky='nes', padx=3, pady=3) + self.comp_advanced_btn.grid(column=2, row=4, sticky="nes", padx=3, pady=3) self.cb_comp_apply_preset() self.cb_no_compress() - - def cb_comp_apply_preset(self, *args): + + def cb_comp_apply_preset(self, *args: Any): selection = self.gui.get_preset() - if selection == 'auto': - if self.gui.get_input_name() == 'local': + if selection == "auto": + if self.gui.get_input_name() == "local": self.gui.no_compress_var.set(True) else: self.gui.no_compress_var.set(False) - self.gui.fps_min_var.set(self.gui.compression_presets[selection]['fps']['min']) - self.gui.fps_max_var.set(self.gui.compression_presets[selection]['fps']['max']) - self.gui.fps_power_var.set(self.gui.compression_presets[selection]['fps']['power']) - self.gui.res_w_min_var.set(self.gui.compression_presets[selection]['res']['w']['min']) - self.gui.res_w_max_var.set(self.gui.compression_presets[selection]['res']['w']['max']) - self.gui.res_h_min_var.set(self.gui.compression_presets[selection]['res']['h']['min']) - self.gui.res_h_max_var.set(self.gui.compression_presets[selection]['res']['h']['max']) - self.gui.res_power_var.set(self.gui.compression_presets[selection]['res']['power']) - self.gui.quality_min_var.set(self.gui.compression_presets[selection]['quality']['min']) - self.gui.quality_max_var.set(self.gui.compression_presets[selection]['quality']['max']) - self.gui.quality_power_var.set(self.gui.compression_presets[selection]['quality']['power']) - self.gui.color_min_var.set(self.gui.compression_presets[selection]['color']['min']) - self.gui.color_max_var.set(self.gui.compression_presets[selection]['color']['max']) - self.gui.color_power_var.set(self.gui.compression_presets[selection]['color']['power']) - self.gui.duration_min_var.set(self.gui.compression_presets[selection]['duration']['min']) - self.gui.duration_max_var.set(self.gui.compression_presets[selection]['duration']['max']) - self.gui.img_size_max_var.set(self.gui.compression_presets[selection]['size_max']['img']) - self.gui.vid_size_max_var.set(self.gui.compression_presets[selection]['size_max']['vid']) - self.gui.img_format_var.set(self.gui.compression_presets[selection]['format']['img']) - self.gui.vid_format_var.set(self.gui.compression_presets[selection]['format']['vid']) - self.gui.fake_vid_var.set(self.gui.compression_presets[selection]['fake_vid']) - self.gui.scale_filter_var.set(self.gui.compression_presets[selection]['scale_filter']) - self.gui.quantize_method_var.set(self.gui.compression_presets[selection]['quantize_method']) - self.gui.default_emoji_var.set(self.gui.compression_presets[selection]['default_emoji']) - self.gui.steps_var.set(self.gui.compression_presets[selection]['steps']) + self.gui.fps_min_var.set(self.gui.compression_presets[selection]["fps"]["min"]) + self.gui.fps_max_var.set(self.gui.compression_presets[selection]["fps"]["max"]) + self.gui.fps_power_var.set( + self.gui.compression_presets[selection]["fps"]["power"] + ) + self.gui.res_w_min_var.set( + self.gui.compression_presets[selection]["res"]["w"]["min"] + ) + self.gui.res_w_max_var.set( + self.gui.compression_presets[selection]["res"]["w"]["max"] + ) + self.gui.res_h_min_var.set( + self.gui.compression_presets[selection]["res"]["h"]["min"] + ) + self.gui.res_h_max_var.set( + self.gui.compression_presets[selection]["res"]["h"]["max"] + ) + self.gui.res_power_var.set( + self.gui.compression_presets[selection]["res"]["power"] + ) + self.gui.quality_min_var.set( + self.gui.compression_presets[selection]["quality"]["min"] + ) + self.gui.quality_max_var.set( + self.gui.compression_presets[selection]["quality"]["max"] + ) + self.gui.quality_power_var.set( + self.gui.compression_presets[selection]["quality"]["power"] + ) + self.gui.color_min_var.set( + self.gui.compression_presets[selection]["color"]["min"] + ) + self.gui.color_max_var.set( + self.gui.compression_presets[selection]["color"]["max"] + ) + self.gui.color_power_var.set( + self.gui.compression_presets[selection]["color"]["power"] + ) + self.gui.duration_min_var.set( + self.gui.compression_presets[selection]["duration"]["min"] + ) + self.gui.duration_max_var.set( + self.gui.compression_presets[selection]["duration"]["max"] + ) + self.gui.img_size_max_var.set( + self.gui.compression_presets[selection]["size_max"]["img"] + ) + self.gui.vid_size_max_var.set( + self.gui.compression_presets[selection]["size_max"]["vid"] + ) + self.gui.img_format_var.set( + self.gui.compression_presets[selection]["format"]["img"] + ) + self.gui.vid_format_var.set( + self.gui.compression_presets[selection]["format"]["vid"] + ) + self.gui.fake_vid_var.set(self.gui.compression_presets[selection]["fake_vid"]) + self.gui.scale_filter_var.set( + self.gui.compression_presets[selection]["scale_filter"] + ) + self.gui.quantize_method_var.set( + self.gui.compression_presets[selection]["quantize_method"] + ) + self.gui.default_emoji_var.set( + self.gui.compression_presets[selection]["default_emoji"] + ) + self.gui.steps_var.set(self.gui.compression_presets[selection]["steps"]) self.cb_no_compress() self.gui.highlight_fields() - - def cb_compress_advanced(self, *args): + + def cb_compress_advanced(self, *args: Any): AdvancedCompressionWindow(self.gui) - - def cb_no_compress(self, *args): - if self.gui.no_compress_var.get() == True: - state = 'disabled' + + def cb_no_compress(self, *args: Any): + if self.gui.no_compress_var.get() is True: + state = "disabled" else: - state = 'normal' - + state = "normal" + self.comp_advanced_btn.config(state=state) self.steps_entry.config(state=state) self.processes_entry.config(state=state) - + def set_inputs_comp(self, state: str): self.comp_preset_opt.config(state=state) self.comp_advanced_btn.config(state=state) self.steps_entry.config(state=state) self.processes_entry.config(state=state) - + def set_states(self, state: str): self.no_compress_cbox.config(state=state) self.set_inputs_comp(state=state) diff --git a/src/sticker_convert/gui_components/frames/config_frame.py b/src/sticker_convert/gui_components/frames/config_frame.py index 99baed1..f7ae44d 100644 --- a/src/sticker_convert/gui_components/frames/config_frame.py +++ b/src/sticker_convert/gui_components/frames/config_frame.py @@ -1,75 +1,110 @@ #!/usr/bin/env python3 import os import platform -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ttkbootstrap import Button, Checkbutton, Label, LabelFrame # type: ignore if TYPE_CHECKING: - from sticker_convert.gui_components.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.definitions import CONFIG_DIR # type: ignore -from sticker_convert.utils.files.run_bin import RunBin # type: ignore +from sticker_convert.definitions import CONFIG_DIR +from sticker_convert.utils.files.run_bin import RunBin class ConfigFrame(LabelFrame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(ConfigFrame, self).__init__(*args, **kwargs) self.grid_columnconfigure(1, weight=1) self.grid_columnconfigure(3, weight=1) - self.settings_save_cred_lbl = Label(self, text='Save credentials', width=18, justify='left', anchor='w') - self.settings_save_cred_cbox = Checkbutton(self, variable=self.gui.settings_save_cred_var, onvalue=True, offvalue=False, bootstyle='success-round-toggle') + self.settings_save_cred_lbl = Label( + self, text="Save credentials", width=18, justify="left", anchor="w" + ) + self.settings_save_cred_cbox = Checkbutton( + self, + variable=self.gui.settings_save_cred_var, + onvalue=True, + offvalue=False, + bootstyle="success-round-toggle", # type: ignore + ) - self.settings_clear_cred_lbl = Label(self, text='Clear credentials', width=18, justify='left', anchor='w') - self.settings_clear_cred_btn = Button(self, text='Clear...', command=self.cb_clear_cred, bootstyle='secondary') + self.settings_clear_cred_lbl = Label( + self, text="Clear credentials", width=18, justify="left", anchor="w" + ) + self.settings_clear_cred_btn = Button( + self, + text="Clear...", + command=self.cb_clear_cred, + bootstyle="secondary", # type: ignore + ) - self.settings_restore_default_lbl = Label(self, text='Restore default config', width=18, justify='left', anchor='w') - self.settings_restore_default_btn = Button(self, text='Restore...', command=self.cb_restore_default, bootstyle='secondary') + self.settings_restore_default_lbl = Label( + self, text="Restore default config", width=18, justify="left", anchor="w" + ) + self.settings_restore_default_btn = Button( + self, + text="Restore...", + command=self.cb_restore_default, + bootstyle="secondary", # type: ignore + ) - self.settings_open_dir_lbl = Label(self, text='Config directory', width=18, justify='left', anchor='w') - self.settings_open_dir_btn = Button(self, text='Open...', command=self.cb_open_config_directory, bootstyle='secondary') + self.settings_open_dir_lbl = Label( + self, text="Config directory", width=18, justify="left", anchor="w" + ) + self.settings_open_dir_btn = Button( + self, + text="Open...", + command=self.cb_open_config_directory, + bootstyle="secondary", # type: ignore + ) - self.settings_save_cred_lbl.grid(column=0, row=0, sticky='w', padx=3, pady=3) - self.settings_save_cred_cbox.grid(column=1, row=0, sticky='w', padx=3, pady=3) + self.settings_save_cred_lbl.grid(column=0, row=0, sticky="w", padx=3, pady=3) + self.settings_save_cred_cbox.grid(column=1, row=0, sticky="w", padx=3, pady=3) - self.settings_clear_cred_lbl.grid(column=2, row=0, sticky='w', padx=3, pady=3) - self.settings_clear_cred_btn.grid(column=3, row=0, sticky='w', padx=3, pady=3) + self.settings_clear_cred_lbl.grid(column=2, row=0, sticky="w", padx=3, pady=3) + self.settings_clear_cred_btn.grid(column=3, row=0, sticky="w", padx=3, pady=3) - self.settings_open_dir_lbl.grid(column=0, row=1, sticky='w', padx=3, pady=3) - self.settings_open_dir_btn.grid(column=1, row=1, sticky='w', padx=3, pady=3) + self.settings_open_dir_lbl.grid(column=0, row=1, sticky="w", padx=3, pady=3) + self.settings_open_dir_btn.grid(column=1, row=1, sticky="w", padx=3, pady=3) - self.settings_restore_default_lbl.grid(column=2, row=1, sticky='w', padx=3, pady=3) - self.settings_restore_default_btn.grid(column=3, row=1, sticky='w', padx=3, pady=3) - - def cb_clear_cred(self, *args, **kwargs): - response = self.gui.cb_ask_bool('Are you sure you want to clear credentials?') - if response == True: + self.settings_restore_default_lbl.grid( + column=2, row=1, sticky="w", padx=3, pady=3 + ) + self.settings_restore_default_btn.grid( + column=3, row=1, sticky="w", padx=3, pady=3 + ) + + def cb_clear_cred(self, *args: Any, **kwargs: Any): + response = self.gui.cb_ask_bool("Are you sure you want to clear credentials?") + if response is True: self.gui.delete_creds() self.gui.load_jsons() self.gui.apply_creds() self.gui.highlight_fields() - self.gui.cb_msg_block('Credentials cleared.') - - def cb_restore_default(self, *args, **kwargs): - response = self.gui.cb_ask_bool('Are you sure you want to restore default config? (This will not clear credentials.)') - if response == True: + self.gui.cb_msg_block("Credentials cleared.") + + def cb_restore_default(self, *args: Any, **kwargs: Any): + response = self.gui.cb_ask_bool( + "Are you sure you want to restore default config? (This will not clear credentials.)" + ) + if response is True: self.gui.delete_config() self.gui.load_jsons() self.gui.apply_config() self.gui.highlight_fields() - self.gui.cb_msg_block('Restored to default config.') - - def cb_open_config_directory(self, *args, **kwargs): - self.gui.cb_msg(msg=f'Config is located at {CONFIG_DIR}') - if platform.system() == 'Windows': - os.startfile(CONFIG_DIR) - elif platform.system() == 'Darwin': - RunBin.run_cmd(['open', CONFIG_DIR], silence=True) + self.gui.cb_msg_block("Restored to default config.") + + def cb_open_config_directory(self, *args: Any, **kwargs: Any): + self.gui.cb_msg(msg=f"Config is located at {CONFIG_DIR}") + if platform.system() == "Windows": + os.startfile(CONFIG_DIR) # type: ignore + elif platform.system() == "Darwin": + RunBin.run_cmd(["open", str(CONFIG_DIR)], silence=True) else: - RunBin.run_cmd(['xdg-open', CONFIG_DIR], silence=True) + RunBin.run_cmd(["xdg-open", str(CONFIG_DIR)], silence=True) def set_states(self, state: str): self.settings_save_cred_cbox.config(state=state) diff --git a/src/sticker_convert/gui_components/frames/control_frame.py b/src/sticker_convert/gui_components/frames/control_frame.py index d39153e..cc45fab 100644 --- a/src/sticker_convert/gui_components/frames/control_frame.py +++ b/src/sticker_convert/gui_components/frames/control_frame.py @@ -1,24 +1,30 @@ #!/usr/bin/env python3 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ttkbootstrap import Button, Frame # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI + class ControlFrame(Frame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(ControlFrame, self).__init__(*args, **kwargs) - self.start_btn = Button(self, text='Start', command=self.cb_start_btn, bootstyle='default') - - self.start_btn.pack(expand=True, fill='x') - - def cb_start_btn(self, *args, **kwargs): + self.start_btn = Button( + self, + text="Start", + command=self.cb_start_btn, + bootstyle="default", # type: ignore + ) + + self.start_btn.pack(expand=True, fill="x") + + def cb_start_btn(self, *args: Any, **kwargs: Any): if self.gui.job: - response = self.gui.cb_ask_bool('Cancel job?') - if response == True: + response = self.gui.cb_ask_bool("Cancel job?") + if response is True: self.gui.cancel_job() else: - self.gui.start_job() \ No newline at end of file + self.gui.start_job() diff --git a/src/sticker_convert/gui_components/frames/cred_frame.py b/src/sticker_convert/gui_components/frames/cred_frame.py index 72820b4..f7363a2 100644 --- a/src/sticker_convert/gui_components/frames/cred_frame.py +++ b/src/sticker_convert/gui_components/frames/cred_frame.py @@ -1,87 +1,161 @@ #!/usr/bin/env python3 import webbrowser -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from ttkbootstrap import Button, Entry, Label, LabelFrame # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore -from sticker_convert.gui_components.windows.kakao_get_auth_window import KakaoGetAuthWindow # type: ignore -from sticker_convert.gui_components.windows.line_get_auth_window import LineGetAuthWindow # type: ignore -from sticker_convert.gui_components.windows.signal_get_auth_window import SignalGetAuthWindow # type: ignore +from sticker_convert.gui_components.frames.right_clicker import RightClicker +from sticker_convert.gui_components.windows.kakao_get_auth_window import ( + KakaoGetAuthWindow, +) +from sticker_convert.gui_components.windows.line_get_auth_window import ( + LineGetAuthWindow, +) +from sticker_convert.gui_components.windows.signal_get_auth_window import ( + SignalGetAuthWindow, +) class CredFrame(LabelFrame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(CredFrame, self).__init__(*args, **kwargs) self.grid_columnconfigure(1, weight=1) - self.signal_uuid_lbl = Label(self, text='Signal uuid', width=18, justify='left', anchor='w') - self.signal_uuid_entry = Entry(self, textvariable=self.gui.signal_uuid_var, width=50, validate="focusout", validatecommand=self.gui.highlight_fields) - self.signal_uuid_entry.bind('', RightClicker) - - self.signal_password_lbl = Label(self, text='Signal password', justify='left', anchor='w') - self.signal_password_entry = Entry(self, textvariable=self.gui.signal_password_var, width=50, validate="focusout", validatecommand=self.gui.highlight_fields) - self.signal_password_entry.bind('', RightClicker) - - self.signal_get_auth_btn = Button(self, text='Generate', command=self.cb_signal_get_auth, bootstyle='secondary') - - self.telegram_token_lbl = Label(self, text='Telegram token', justify='left', anchor='w') - self.telegram_token_entry = Entry(self, textvariable=self.gui.telegram_token_var, width=50, validate="focusout", validatecommand=self.gui.highlight_fields) - self.telegram_token_entry.bind('', RightClicker) - - self.telegram_userid_lbl = Label(self, text='Telegram user_id', justify='left', anchor='w') - self.telegram_userid_entry = Entry(self, textvariable=self.gui.telegram_userid_var, width=50, validate="focusout", validatecommand=self.gui.highlight_fields) - self.telegram_userid_entry.bind('', RightClicker) - - self.kakao_auth_token_lbl = Label(self, text='Kakao auth_token', justify='left', anchor='w') - self.kakao_auth_token_entry = Entry(self, textvariable=self.gui.kakao_auth_token_var, width=35) - self.kakao_auth_token_entry.bind('', RightClicker) - self.kakao_get_auth_btn = Button(self, text='Generate', command=self.cb_kakao_get_auth, bootstyle='secondary') - - self.line_cookies_lbl = Label(self, text='Line cookies', width=18, justify='left', anchor='w') - self.line_cookies_entry = Entry(self, textvariable=self.gui.line_cookies_var, width=35) - self.line_cookies_entry.bind('', RightClicker) - self.line_get_auth_btn = Button(self, text='Generate', command=self.cb_line_get_auth, bootstyle='secondary') - - self.help_btn = Button(self, text='Get help', command=self.cb_cred_help, bootstyle='secondary') - - self.signal_uuid_lbl.grid(column=0, row=0, sticky='w', padx=3, pady=3) - self.signal_uuid_entry.grid(column=1, row=0, columnspan=2, sticky='w', padx=3, pady=3) - self.signal_password_lbl.grid(column=0, row=1, sticky='w', padx=3, pady=3) - self.signal_password_entry.grid(column=1, row=1, columnspan=2, sticky='w', padx=3, pady=3) - self.signal_get_auth_btn.grid(column=2, row=2, sticky='e', padx=3, pady=3) - self.telegram_token_lbl.grid(column=0, row=3, sticky='w', padx=3, pady=3) - self.telegram_token_entry.grid(column=1, row=3, columnspan=2, sticky='w', padx=3, pady=3) - self.telegram_userid_lbl.grid(column=0, row=4, sticky='w', padx=3, pady=3) - self.telegram_userid_entry.grid(column=1, row=4, columnspan=2, sticky='w', padx=3, pady=3) - self.kakao_auth_token_lbl.grid(column=0, row=5, sticky='w', padx=3, pady=3) - self.kakao_auth_token_entry.grid(column=1, row=5, sticky='w', padx=3, pady=3) - self.kakao_get_auth_btn.grid(column=2, row=5, sticky='e', padx=3, pady=3) - self.line_cookies_lbl.grid(column=0, row=6, sticky='w', padx=3, pady=3) - self.line_cookies_entry.grid(column=1, row=6, sticky='w', padx=3, pady=3) - self.line_get_auth_btn.grid(column=2, row=6, sticky='e', padx=3, pady=3) - self.help_btn.grid(column=2, row=8, sticky='e', padx=3, pady=3) - - def cb_cred_help(self, *args): - faq_site = 'https://github.com/laggykiller/sticker-convert#faq' + self.signal_uuid_lbl = Label( + self, text="Signal uuid", width=18, justify="left", anchor="w" + ) + self.signal_uuid_entry = Entry( + self, + textvariable=self.gui.signal_uuid_var, + width=50, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.signal_uuid_entry.bind("", RightClicker) + + self.signal_password_lbl = Label( + self, text="Signal password", justify="left", anchor="w" + ) + self.signal_password_entry = Entry( + self, + textvariable=self.gui.signal_password_var, + width=50, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.signal_password_entry.bind("", RightClicker) + + self.signal_get_auth_btn = Button( + self, + text="Generate", + command=self.cb_signal_get_auth, + bootstyle="secondary", # type: ignore + ) + + self.telegram_token_lbl = Label( + self, text="Telegram token", justify="left", anchor="w" + ) + self.telegram_token_entry = Entry( + self, + textvariable=self.gui.telegram_token_var, + width=50, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.telegram_token_entry.bind("", RightClicker) + + self.telegram_userid_lbl = Label( + self, text="Telegram user_id", justify="left", anchor="w" + ) + self.telegram_userid_entry = Entry( + self, + textvariable=self.gui.telegram_userid_var, + width=50, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.telegram_userid_entry.bind("", RightClicker) + + self.kakao_auth_token_lbl = Label( + self, text="Kakao auth_token", justify="left", anchor="w" + ) + self.kakao_auth_token_entry = Entry( + self, textvariable=self.gui.kakao_auth_token_var, width=35 + ) + self.kakao_auth_token_entry.bind("", RightClicker) + self.kakao_get_auth_btn = Button( + self, + text="Generate", + command=self.cb_kakao_get_auth, + bootstyle="secondary", # type: ignore + ) + + self.line_cookies_lbl = Label( + self, text="Line cookies", width=18, justify="left", anchor="w" + ) + self.line_cookies_entry = Entry( + self, textvariable=self.gui.line_cookies_var, width=35 + ) + self.line_cookies_entry.bind("", RightClicker) + self.line_get_auth_btn = Button( + self, + text="Generate", + command=self.cb_line_get_auth, + bootstyle="secondary", # type: ignore + ) + + self.help_btn = Button( + self, + text="Get help", + command=self.cb_cred_help, + bootstyle="secondary", # type: ignore + ) + + self.signal_uuid_lbl.grid(column=0, row=0, sticky="w", padx=3, pady=3) + self.signal_uuid_entry.grid( + column=1, row=0, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.signal_password_lbl.grid(column=0, row=1, sticky="w", padx=3, pady=3) + self.signal_password_entry.grid( + column=1, row=1, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.signal_get_auth_btn.grid(column=2, row=2, sticky="e", padx=3, pady=3) + self.telegram_token_lbl.grid(column=0, row=3, sticky="w", padx=3, pady=3) + self.telegram_token_entry.grid( + column=1, row=3, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.telegram_userid_lbl.grid(column=0, row=4, sticky="w", padx=3, pady=3) + self.telegram_userid_entry.grid( + column=1, row=4, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.kakao_auth_token_lbl.grid(column=0, row=5, sticky="w", padx=3, pady=3) + self.kakao_auth_token_entry.grid(column=1, row=5, sticky="w", padx=3, pady=3) + self.kakao_get_auth_btn.grid(column=2, row=5, sticky="e", padx=3, pady=3) + self.line_cookies_lbl.grid(column=0, row=6, sticky="w", padx=3, pady=3) + self.line_cookies_entry.grid(column=1, row=6, sticky="w", padx=3, pady=3) + self.line_get_auth_btn.grid(column=2, row=6, sticky="e", padx=3, pady=3) + self.help_btn.grid(column=2, row=8, sticky="e", padx=3, pady=3) + + def cb_cred_help(self, *args: Any): + faq_site = "https://github.com/laggykiller/sticker-convert#faq" success = webbrowser.open(faq_site) if not success: - self.gui.cb_ask_str('You can get help from:', initialvalue=faq_site) - - def cb_kakao_get_auth(self, *args): + self.gui.cb_ask_str("You can get help from:", initialvalue=faq_site) + + def cb_kakao_get_auth(self, *args: Any): KakaoGetAuthWindow(self.gui) - - def cb_signal_get_auth(self, *args): + + def cb_signal_get_auth(self, *args: Any): SignalGetAuthWindow(self.gui) - - def cb_line_get_auth(self, *args): + + def cb_line_get_auth(self, *args: Any): LineGetAuthWindow(self.gui) - + def set_states(self, state: str): self.signal_uuid_entry.config(state=state) self.signal_password_entry.config(state=state) diff --git a/src/sticker_convert/gui_components/frames/input_frame.py b/src/sticker_convert/gui_components/frames/input_frame.py index c6f2ea4..01e15a0 100644 --- a/src/sticker_convert/gui_components/frames/input_frame.py +++ b/src/sticker_convert/gui_components/frames/input_frame.py @@ -1,81 +1,135 @@ #!/usr/bin/env python3 from pathlib import Path from tkinter import filedialog -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from ttkbootstrap import (Button, Entry, Label, LabelFrame, # type: ignore - OptionMenu) +from ttkbootstrap import Button, Entry, Label, LabelFrame, OptionMenu # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.definitions import DEFAULT_DIR # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore -from sticker_convert.utils.url_detect import UrlDetect # type: ignore +from sticker_convert.definitions import DEFAULT_DIR +from sticker_convert.gui_components.frames.right_clicker import RightClicker +from sticker_convert.utils.url_detect import UrlDetect class InputFrame(LabelFrame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(InputFrame, self).__init__(*args, **kwargs) - self.input_option_lbl = Label(self, text='Input source', width=15, justify='left', anchor='w') - input_full_names = [i['full_name'] for i in self.gui.input_presets.values()] - default_input_full_name = self.gui.input_presets[self.gui.default_input_mode]['full_name'] - self.input_option_opt = OptionMenu(self, self.gui.input_option_display_var, default_input_full_name, *input_full_names, command=self.cb_input_option, bootstyle='secondary') + self.input_option_lbl = Label( + self, text="Input source", width=15, justify="left", anchor="w" + ) + input_full_names = [i["full_name"] for i in self.gui.input_presets.values()] + default_input_full_name = self.gui.input_presets[self.gui.default_input_mode][ + "full_name" + ] + self.input_option_opt = OptionMenu( + self, + self.gui.input_option_display_var, + default_input_full_name, + *input_full_names, + command=self.cb_input_option, + bootstyle="secondary", # type: ignore + ) self.input_option_opt.config(width=32) - self.input_setdir_lbl = Label(self, text='Input directory', width=35, justify='left', anchor='w') - self.input_setdir_entry = Entry(self, textvariable=self.gui.input_setdir_var, width=60, validatecommand=self.gui.highlight_fields) - self.input_setdir_entry.bind('', RightClicker) - self.setdir_btn = Button(self, text='Choose directory', command=self.cb_set_indir, width=16, bootstyle='secondary') - - self.address_lbl = Label(self, text=self.gui.input_presets[self.gui.default_input_mode]['address_lbls'], width=18, justify='left', anchor='w') - self.address_entry = Entry(self, textvariable=self.gui.input_address_var, width=80, validate="focusout", validatecommand=self.cb_input_option) - self.address_entry.bind('', RightClicker) - self.address_tip = Label(self, text=self.gui.input_presets[self.gui.default_input_mode]['example'], justify='left', anchor='w') - - self.input_option_lbl.grid(column=0, row=0, sticky='w', padx=3, pady=3) - self.input_option_opt.grid(column=1, row=0, columnspan=2, sticky='w', padx=3, pady=3) - self.input_setdir_lbl.grid(column=0, row=1, columnspan=2, sticky='w', padx=3, pady=3) - self.input_setdir_entry.grid(column=1, row=1, sticky='w', padx=3, pady=3) - self.setdir_btn.grid(column=2, row=1, sticky='e', padx=3, pady=3) - self.address_lbl.grid(column=0, row=2, sticky='w', padx=3, pady=3) - self.address_entry.grid(column=1, row=2, columnspan=2, sticky='w', padx=3, pady=3) - self.address_tip.grid(column=0, row=3, columnspan=3, sticky='w', padx=3, pady=3) - - preset = [k for k, v in self.gui.input_presets.items() if v['full_name'] == self.gui.input_option_display_var.get()][0] - if preset == 'local': - self.address_entry.config(state='disabled') + self.input_setdir_lbl = Label( + self, text="Input directory", width=35, justify="left", anchor="w" + ) + self.input_setdir_entry = Entry( + self, + textvariable=self.gui.input_setdir_var, + width=60, + validatecommand=self.gui.highlight_fields, + ) + self.input_setdir_entry.bind("", RightClicker) + self.setdir_btn = Button( + self, + text="Choose directory", + command=self.cb_set_indir, + width=16, + bootstyle="secondary", # type: ignore + ) + + self.address_lbl = Label( + self, + text=self.gui.input_presets[self.gui.default_input_mode]["address_lbls"], + width=18, + justify="left", + anchor="w", + ) + self.address_entry = Entry( + self, + textvariable=self.gui.input_address_var, + width=80, + validate="focusout", + validatecommand=self.cb_input_option, + ) + self.address_entry.bind("", RightClicker) + self.address_tip = Label( + self, + text=self.gui.input_presets[self.gui.default_input_mode]["example"], + justify="left", + anchor="w", + ) + + self.input_option_lbl.grid(column=0, row=0, sticky="w", padx=3, pady=3) + self.input_option_opt.grid( + column=1, row=0, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.input_setdir_lbl.grid( + column=0, row=1, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.input_setdir_entry.grid(column=1, row=1, sticky="w", padx=3, pady=3) + self.setdir_btn.grid(column=2, row=1, sticky="e", padx=3, pady=3) + self.address_lbl.grid(column=0, row=2, sticky="w", padx=3, pady=3) + self.address_entry.grid( + column=1, row=2, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.address_tip.grid(column=0, row=3, columnspan=3, sticky="w", padx=3, pady=3) + + preset = [ + k + for k, v in self.gui.input_presets.items() + if v["full_name"] == self.gui.input_option_display_var.get() + ][0] + if preset == "local": + self.address_entry.config(state="disabled") else: - self.address_entry.config(state='normal') - - def cb_set_indir(self, *args): + self.address_entry.config(state="normal") + + def cb_set_indir(self, *args: Any): orig_input_dir = self.gui.input_setdir_var.get() if not Path(orig_input_dir).is_dir(): orig_input_dir = DEFAULT_DIR input_dir = filedialog.askdirectory(initialdir=orig_input_dir) if input_dir: self.gui.input_setdir_var.set(input_dir) - - def cb_input_option(self, *args): + + def cb_input_option(self, *args: Any): input_option_display = self.gui.get_input_display_name() - - if input_option_display == 'auto': + + if input_option_display == "auto": url = self.gui.input_address_var.get() download_option = UrlDetect.detect(url) - if download_option == None: - self.gui.input_option_true_var.set(self.gui.input_presets['auto']['full_name']) + if download_option is None: + self.gui.input_option_true_var.set( + self.gui.input_presets["auto"]["full_name"] + ) else: - self.gui.input_option_true_var.set(self.gui.input_presets[download_option]['full_name']) + self.gui.input_option_true_var.set( + self.gui.input_presets[download_option]["full_name"] + ) else: self.gui.input_option_true_var.set(self.gui.input_option_display_var.get()) - + self.gui.highlight_fields() return True - + def set_states(self, state: str): self.input_option_opt.config(state=state) self.address_entry.config(state=state) diff --git a/src/sticker_convert/gui_components/frames/output_frame.py b/src/sticker_convert/gui_components/frames/output_frame.py index d180b07..ff17f7c 100644 --- a/src/sticker_convert/gui_components/frames/output_frame.py +++ b/src/sticker_convert/gui_components/frames/output_frame.py @@ -1,66 +1,107 @@ #!/usr/bin/env python3 from pathlib import Path from tkinter import filedialog -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any -from ttkbootstrap import (Button, Entry, Label, LabelFrame, # type: ignore - OptionMenu) +from ttkbootstrap import Button, Entry, Label, LabelFrame, OptionMenu # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.definitions import DEFAULT_DIR # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore +from sticker_convert.definitions import DEFAULT_DIR +from sticker_convert.gui_components.frames.right_clicker import RightClicker class OutputFrame(LabelFrame): - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui super(OutputFrame, self).__init__(*args, **kwargs) - self.output_option_lbl = Label(self, text='Output options', width=18, justify='left', anchor='w') - output_full_names = [i['full_name'] for i in self.gui.output_presets.values()] - defult_output_full_name = self.gui.output_presets[self.gui.default_output_mode]['full_name'] - self.output_option_opt = OptionMenu(self, self.gui.output_option_display_var, defult_output_full_name, *output_full_names, command=self.cb_output_option, bootstyle='secondary') + self.output_option_lbl = Label( + self, text="Output options", width=18, justify="left", anchor="w" + ) + output_full_names = [i["full_name"] for i in self.gui.output_presets.values()] + defult_output_full_name = self.gui.output_presets[self.gui.default_output_mode][ + "full_name" + ] + self.output_option_opt = OptionMenu( + self, + self.gui.output_option_display_var, + defult_output_full_name, + *output_full_names, + command=self.cb_output_option, + bootstyle="secondary", # type: ignore + ) self.output_option_opt.config(width=32) - self.output_setdir_lbl = Label(self, text='Output directory', justify='left', anchor='w') - self.output_setdir_entry = Entry(self, textvariable=self.gui.output_setdir_var, width=60, validatecommand=self.gui.highlight_fields) - self.output_setdir_entry.bind('', RightClicker) - - self.output_setdir_btn = Button(self, text='Choose directory', command=self.cb_set_outdir, width=16, bootstyle='secondary') + self.output_setdir_lbl = Label( + self, text="Output directory", justify="left", anchor="w" + ) + self.output_setdir_entry = Entry( + self, + textvariable=self.gui.output_setdir_var, + width=60, + validatecommand=self.gui.highlight_fields, + ) + self.output_setdir_entry.bind("", RightClicker) - self.title_lbl = Label(self, text='Title') - self.title_entry = Entry(self, textvariable=self.gui.title_var, width=80, validate="focusout", validatecommand=self.gui.highlight_fields) - self.title_entry.bind('', RightClicker) - - self.author_lbl = Label(self, text='Author') - self.author_entry = Entry(self, textvariable=self.gui.author_var, width=80, validate="focusout", validatecommand=self.gui.highlight_fields) - self.author_entry.bind('', RightClicker) + self.output_setdir_btn = Button( + self, + text="Choose directory", + command=self.cb_set_outdir, + width=16, + bootstyle="secondary", # type: ignore + ) - self.output_option_lbl.grid(column=0, row=0, sticky='w', padx=3, pady=3) - self.output_option_opt.grid(column=1, row=0, columnspan=2, sticky='w', padx=3, pady=3) - self.output_setdir_lbl.grid(column=0, row=1, columnspan=2, sticky='w', padx=3, pady=3) - self.output_setdir_entry.grid(column=1, row=1, sticky='w', padx=3, pady=3) - self.output_setdir_btn.grid(column=2, row=1, sticky='e', padx=3, pady=3) - self.title_lbl.grid(column=0, row=2, sticky='w', padx=3, pady=3) - self.title_entry.grid(column=1, columnspan=2, row=2, sticky='w', padx=3, pady=3) - self.author_lbl.grid(column=0, row=3, sticky='w', padx=3, pady=3) - self.author_entry.grid(column=1, columnspan=2, row=3, sticky='w', padx=3, pady=3) - - def cb_set_outdir(self, *args): + self.title_lbl = Label(self, text="Title") + self.title_entry = Entry( + self, + textvariable=self.gui.title_var, + width=80, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.title_entry.bind("", RightClicker) + + self.author_lbl = Label(self, text="Author") + self.author_entry = Entry( + self, + textvariable=self.gui.author_var, + width=80, + validate="focusout", + validatecommand=self.gui.highlight_fields, + ) + self.author_entry.bind("", RightClicker) + + self.output_option_lbl.grid(column=0, row=0, sticky="w", padx=3, pady=3) + self.output_option_opt.grid( + column=1, row=0, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.output_setdir_lbl.grid( + column=0, row=1, columnspan=2, sticky="w", padx=3, pady=3 + ) + self.output_setdir_entry.grid(column=1, row=1, sticky="w", padx=3, pady=3) + self.output_setdir_btn.grid(column=2, row=1, sticky="e", padx=3, pady=3) + self.title_lbl.grid(column=0, row=2, sticky="w", padx=3, pady=3) + self.title_entry.grid(column=1, columnspan=2, row=2, sticky="w", padx=3, pady=3) + self.author_lbl.grid(column=0, row=3, sticky="w", padx=3, pady=3) + self.author_entry.grid( + column=1, columnspan=2, row=3, sticky="w", padx=3, pady=3 + ) + + def cb_set_outdir(self, *args: Any): orig_output_dir = self.gui.output_setdir_var.get() if not Path(orig_output_dir).is_dir(): orig_output_dir = DEFAULT_DIR output_dir = filedialog.askdirectory(initialdir=orig_output_dir) if output_dir: self.gui.output_setdir_var.set(output_dir) - - def cb_output_option(self, *args): + + def cb_output_option(self, *args: Any): self.gui.output_option_true_var.set(self.gui.output_option_display_var.get()) self.gui.comp_frame.cb_comp_apply_preset() self.gui.highlight_fields() - + def set_states(self, state: str): self.title_entry.config(state=state) self.author_entry.config(state=state) diff --git a/src/sticker_convert/gui_components/frames/progress_frame.py b/src/sticker_convert/gui_components/frames/progress_frame.py index 57d9b19..13aa2ad 100644 --- a/src/sticker_convert/gui_components/frames/progress_frame.py +++ b/src/sticker_convert/gui_components/frames/progress_frame.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from tqdm import tqdm from ttkbootstrap import LabelFrame, Progressbar # type: ignore from ttkbootstrap.scrolled import ScrolledText # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore +from sticker_convert.gui_components.frames.right_clicker import RightClicker class ProgressFrame(LabelFrame): @@ -16,53 +16,55 @@ class ProgressFrame(LabelFrame): progress_bar_steps = 0 auto_scroll = True - def __init__(self, gui: "GUI", *args, **kwargs): + def __init__(self, gui: "GUI", *args: Any, **kwargs: Any): self.gui = gui - super(ProgressFrame, self).__init__(*args, **kwargs) + super(ProgressFrame, self).__init__(*args, **kwargs) # type: ignore - self.message_box = ScrolledText(self, height=15, wrap='word') - self.message_box._text.bind('', RightClicker) - self.message_box._text.config(state='disabled') - self.progress_bar = Progressbar(self, orient='horizontal', mode='determinate') + self.message_box = ScrolledText(self, height=15, wrap="word") + self.message_box._text.bind("", RightClicker) # type: ignore + self.message_box._text.config(state="disabled") # type: ignore + self.progress_bar = Progressbar(self, orient="horizontal", mode="determinate") - self.message_box.bind('', self.cb_disable_autoscroll) - self.message_box.bind('', self.cb_enable_autoscroll) + self.message_box.bind("", self.cb_disable_autoscroll) + self.message_box.bind("", self.cb_enable_autoscroll) - self.message_box.pack(expand=True, fill='x') - self.progress_bar.pack(expand=True, fill='x') - - def update_progress_bar(self, set_progress_mode: str = '', steps: int = 0, update_bar: bool = False): + self.message_box.pack(expand=True, fill="x") + self.progress_bar.pack(expand=True, fill="x") + + def update_progress_bar( + self, set_progress_mode: str = "", steps: int = 0, update_bar: bool = False + ): if update_bar and self.progress_bar_cli: self.progress_bar_cli.update() - self.progress_bar['value'] += 100 / self.progress_bar_steps - elif set_progress_mode == 'determinate': + self.progress_bar["value"] += 100 / self.progress_bar_steps + elif set_progress_mode == "determinate": self.progress_bar_cli = tqdm(total=steps) - self.progress_bar.config(mode='determinate') + self.progress_bar.config(mode="determinate") self.progress_bar_steps = steps self.progress_bar.stop() - self.progress_bar['value'] = 0 - elif set_progress_mode == 'indeterminate': + self.progress_bar["value"] = 0 + elif set_progress_mode == "indeterminate": if self.progress_bar_cli: self.progress_bar_cli.close() self.progress_bar_cli = None - self.progress_bar['value'] = 0 - self.progress_bar.config(mode='indeterminate') + self.progress_bar["value"] = 0 + self.progress_bar.config(mode="indeterminate") self.progress_bar.start(50) - elif set_progress_mode == 'clear': + elif set_progress_mode == "clear": if self.progress_bar_cli: self.progress_bar_cli.reset() - self.progress_bar.config(mode='determinate') + self.progress_bar.config(mode="determinate") self.progress_bar.stop() - self.progress_bar['value'] = 0 + self.progress_bar["value"] = 0 - def update_message_box(self, *args, **kwargs): - msg = kwargs.get('msg') - cls = kwargs.get('cls') - file = kwargs.get('file') + def update_message_box(self, *args: Any, **kwargs: Any): + msg = kwargs.get("msg") + cls = kwargs.get("cls") + file = kwargs.get("file") if not msg and len(args) == 1: msg = str(args[0]) - + if msg: if self.progress_bar_cli: self.progress_bar_cli.write(msg) @@ -70,23 +72,23 @@ def update_message_box(self, *args, **kwargs): print(msg, file=file) else: print(msg) - msg += '\n' + msg += "\n" - self.message_box._text.config(state='normal') + self.message_box._text.config(state="normal") # type: ignore if cls: - self.message_box.delete(1.0, 'end') + self.message_box.delete(1.0, "end") # type: ignore if msg: - self.message_box.insert('end', msg) + self.message_box.insert("end", msg) # type: ignore if self.auto_scroll: - self.message_box._text.yview_moveto(1.0) + self.message_box._text.yview_moveto(1.0) # type: ignore - self.message_box._text.config(state='disabled') - - def cb_disable_autoscroll(self, *args): + self.message_box._text.config(state="disabled") # type: ignore + + def cb_disable_autoscroll(self, *args: Any): self.auto_scroll = False - - def cb_enable_autoscroll(self, *args): + + def cb_enable_autoscroll(self, *args: Any): self.auto_scroll = True diff --git a/src/sticker_convert/gui_components/frames/right_clicker.py b/src/sticker_convert/gui_components/frames/right_clicker.py index 927f9d2..67dc1d2 100644 --- a/src/sticker_convert/gui_components/frames/right_clicker.py +++ b/src/sticker_convert/gui_components/frames/right_clicker.py @@ -1,19 +1,23 @@ -from tkinter import Event +#!/usr/bin/env python3 +from typing import Any from ttkbootstrap import Menu # type: ignore # Reference: https://stackoverflow.com/a/57704013 class RightClicker: - def __init__(self, event: Event): + def __init__(self, event: Any): right_click_menu = Menu(None, tearoff=0, takefocus=0) - for txt in ['Cut', 'Copy', 'Paste']: + for txt in ["Cut", "Copy", "Paste"]: right_click_menu.add_command( - label=txt, command=lambda event=event, text=txt: - self.right_click_command(event, text)) + label=txt, + command=lambda event=event, text=txt: self.right_click_command( + event, text + ), + ) - right_click_menu.tk_popup(event.x_root, event.y_root, entry='0') + right_click_menu.tk_popup(event.x_root, event.y_root, entry="0") - def right_click_command(self, event: Event, cmd: str): - event.widget.event_generate(f'<<{cmd}>>') \ No newline at end of file + def right_click_command(self, event: Any, cmd: str): + event.widget.event_generate(f"<<{cmd}>>") diff --git a/src/sticker_convert/gui_components/gui_utils.py b/src/sticker_convert/gui_components/gui_utils.py index 4a181f8..076135f 100644 --- a/src/sticker_convert/gui_components/gui_utils.py +++ b/src/sticker_convert/gui_components/gui_utils.py @@ -4,29 +4,29 @@ import platform from typing import TYPE_CHECKING, Union -from ttkbootstrap import Canvas, Frame, PhotoImage, Scrollbar +from ttkbootstrap import Canvas, Frame, PhotoImage, Scrollbar # type: ignore from sticker_convert.definitions import ROOT_DIR if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore - from sticker_convert.gui_components.windows.base_window import BaseWindow # type: ignore + from sticker_convert.gui import GUI + from sticker_convert.gui_components.windows.base_window import BaseWindow class GUIUtils: @staticmethod def set_icon(window: Union["BaseWindow", "GUI"]): - window.icon = PhotoImage(file=ROOT_DIR/"resources/appicon.png") - window.iconphoto(1, window.icon) + window.icon = PhotoImage(file=ROOT_DIR / "resources/appicon.png") # type: ignore + window.iconphoto(1, window.icon) # type: ignore if platform.system() == "Darwin": - window.iconbitmap(bitmap=ROOT_DIR/"resources/appicon.icns") + window.iconbitmap(bitmap=ROOT_DIR / "resources/appicon.icns") # type: ignore elif platform.system() == "Windows": - window.iconbitmap(bitmap=ROOT_DIR/"resources/appicon.ico") - window.tk.call("wm", "iconphoto", window._w, window.icon) + window.iconbitmap(bitmap=ROOT_DIR / "resources/appicon.ico") # type: ignore + window.tk.call("wm", "iconphoto", window._w, window.icon) # type: ignore @staticmethod def create_scrollable_frame( - window: Union["BaseWindow", "GUI"] + window: Union["BaseWindow", "GUI"], ) -> tuple[Frame, Frame, Canvas, Scrollbar, Scrollbar, Frame]: main_frame = Frame(window) main_frame.pack(fill="both", expand=1) @@ -38,11 +38,13 @@ def create_scrollable_frame( canvas.pack(side="left", fill="both", expand=1) x_scrollbar = Scrollbar( - horizontal_scrollbar_frame, orient="horizontal", command=canvas.xview + horizontal_scrollbar_frame, + orient="horizontal", + command=canvas.xview, # type: ignore ) x_scrollbar.pack(side="bottom", fill="x") - y_scrollbar = Scrollbar(main_frame, orient="vertical", command=canvas.yview) + y_scrollbar = Scrollbar(main_frame, orient="vertical", command=canvas.yview) # type: ignore y_scrollbar.pack(side="right", fill="y") canvas.configure(xscrollcommand=x_scrollbar.set) @@ -65,7 +67,7 @@ def create_scrollable_frame( @staticmethod def finalize_window(window: Union["GUI", "BaseWindow"]): - window.attributes("-alpha", 0) + window.attributes("-alpha", 0) # type: ignore screen_width = window.winfo_screenwidth() screen_height = window.winfo_screenwidth() @@ -91,6 +93,6 @@ def finalize_window(window: Union["GUI", "BaseWindow"]): window.place_window_center() - window.attributes("-alpha", 1) + window.attributes("-alpha", 1) # type: ignore window.focus_force() diff --git a/src/sticker_convert/gui_components/windows/__init__.py b/src/sticker_convert/gui_components/windows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sticker_convert/gui_components/windows/advanced_compression_window.py b/src/sticker_convert/gui_components/windows/advanced_compression_window.py index 889b26b..e478bb2 100644 --- a/src/sticker_convert/gui_components/windows/advanced_compression_window.py +++ b/src/sticker_convert/gui_components/windows/advanced_compression_window.py @@ -2,16 +2,25 @@ from __future__ import annotations from functools import partial -from tkinter import Event +from typing import Any from PIL import Image, ImageDraw, ImageTk -from ttkbootstrap import (Button, Canvas, Checkbutton, Entry, # type: ignore - Frame, Label, LabelFrame, OptionMenu, Scrollbar, - StringVar) - -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore -from sticker_convert.gui_components.windows.base_window import BaseWindow # type: ignore +from ttkbootstrap import ( # type: ignore + Button, + Canvas, + Checkbutton, + Entry, + Frame, + Label, + LabelFrame, + OptionMenu, + Scrollbar, + StringVar, +) + +from sticker_convert.gui_components.frames.right_clicker import RightClicker +from sticker_convert.gui_components.gui_utils import GUIUtils +from sticker_convert.gui_components.windows.base_window import BaseWindow class AdvancedCompressionWindow(BaseWindow): @@ -19,236 +28,475 @@ class AdvancedCompressionWindow(BaseWindow): emoji_visible_rows = 5 emoji_btns: list[tuple[Button, ImageTk.PhotoImage]] = [] - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(AdvancedCompressionWindow, self).__init__(*args, **kwargs) - self.categories = list({entry['category'] for entry in self.gui.emoji_list}) + self.categories = list({entry["category"] for entry in self.gui.emoji_list}) - self.title('Advanced compression options') + self.title("Advanced compression options") - self.frame_advcomp = LabelFrame(self.scrollable_frame, text='Advanced compression option') - self.frame_emoji_search = LabelFrame(self.scrollable_frame, text='Setting default emoji') + self.frame_advcomp = LabelFrame( + self.scrollable_frame, text="Advanced compression option" + ) + self.frame_emoji_search = LabelFrame( + self.scrollable_frame, text="Setting default emoji" + ) self.frame_emoji_canvas = Frame(self.scrollable_frame) self.frame_advcomp.grid_columnconfigure(6, weight=1) cb_msg_block_adv_comp_win = partial(self.gui.cb_msg_block, parent=self) - self.fps_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['fps']), bootstyle='secondary') - self.fps_lbl = Label(self.frame_advcomp, text='Output FPS') - self.fps_min_lbl = Label(self.frame_advcomp, text='Min:') - self.fps_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.fps_min_var, width=8) - self.fps_min_entry.bind('', RightClicker) - self.fps_max_lbl = Label(self.frame_advcomp, text='Max:') - self.fps_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.fps_max_var, width=8) - self.fps_max_entry.bind('', RightClicker) - self.fps_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.fps_disable_var, command=self.cb_disable_fps, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.res_w_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['res']), bootstyle='secondary') - self.res_w_lbl = Label(self.frame_advcomp, text='Output resolution (Width)') - self.res_w_min_lbl = Label(self.frame_advcomp, text='Min:') - self.res_w_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.res_w_min_var, width=8) - self.res_w_min_entry.bind('', RightClicker) - self.res_w_max_lbl = Label(self.frame_advcomp, text='Max:') - self.res_w_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.res_w_max_var, width=8) - self.res_w_max_entry.bind('', RightClicker) - self.res_w_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.res_w_disable_var, command=self.cb_disable_res_w, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.res_h_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['res']), bootstyle='secondary') - self.res_h_lbl = Label(self.frame_advcomp, text='Output resolution (Height)') - self.res_h_min_lbl = Label(self.frame_advcomp, text='Min:') - self.res_h_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.res_h_min_var, width=8) - self.res_h_min_entry.bind('', RightClicker) - self.res_h_max_lbl = Label(self.frame_advcomp, text='Max:') - self.res_h_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.res_h_max_var, width=8) - self.res_h_max_entry.bind('', RightClicker) - self.res_h_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.res_h_disable_var, command=self.cb_disable_res_h, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.quality_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['quality']), bootstyle='secondary') - self.quality_lbl = Label(self.frame_advcomp, text='Output quality (0-100)') - self.quality_min_lbl = Label(self.frame_advcomp, text='Min:') - self.quality_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.quality_min_var, width=8) - self.quality_min_entry.bind('', RightClicker) - self.quality_max_lbl = Label(self.frame_advcomp, text='Max:') - self.quality_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.quality_max_var, width=8) - self.quality_max_entry.bind('', RightClicker) - self.quality_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.quality_disable_var, command=self.cb_disable_quality, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.color_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['color']), bootstyle='secondary') - self.color_lbl = Label(self.frame_advcomp, text='Colors (0-256)') - self.color_min_lbl = Label(self.frame_advcomp, text='Min:') - self.color_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.color_min_var, width=8) - self.color_min_entry.bind('', RightClicker) - self.color_max_lbl = Label(self.frame_advcomp, text='Max:') - self.color_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.color_max_var, width=8) - self.color_max_entry.bind('', RightClicker) - self.color_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.color_disable_var, command=self.cb_disable_color, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.duration_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['duration']), bootstyle='secondary') - self.duration_lbl = Label(self.frame_advcomp, text='Duration (Miliseconds)') - self.duration_min_lbl = Label(self.frame_advcomp, text='Min:') - self.duration_min_entry = Entry(self.frame_advcomp, textvariable=self.gui.duration_min_var, width=8) - self.duration_min_entry.bind('', RightClicker) - self.duration_max_lbl = Label(self.frame_advcomp, text='Max:') - self.duration_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.duration_max_var, width=8) - self.duration_max_entry.bind('', RightClicker) - self.duration_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.duration_disable_var, command=self.cb_disable_duration, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.size_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['size']), bootstyle='secondary') - self.size_lbl = Label(self.frame_advcomp, text='Maximum file size (bytes)') - self.img_size_max_lbl = Label(self.frame_advcomp, text='Img:') - self.img_size_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.img_size_max_var, width=8) - self.img_size_max_entry.bind('', RightClicker) - self.vid_size_max_lbl = Label(self.frame_advcomp, text='Vid:') - self.vid_size_max_entry = Entry(self.frame_advcomp, textvariable=self.gui.vid_size_max_var, width=8) - self.vid_size_max_entry.bind('', RightClicker) - self.size_disable_cbox = Checkbutton(self.frame_advcomp, text="X", variable=self.gui.size_disable_var, command=self.cb_disable_size, onvalue=True, offvalue=False, bootstyle='danger-round-toggle') - - self.format_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['format']), bootstyle='secondary') - self.format_lbl = Label(self.frame_advcomp, text='File format') - self.img_format_lbl = Label(self.frame_advcomp, text='Img:') - self.img_format_entry = Entry(self.frame_advcomp, textvariable=self.gui.img_format_var, width=8) - self.img_format_entry.bind('', RightClicker) - self.vid_format_lbl = Label(self.frame_advcomp, text='Vid:') - self.vid_format_entry = Entry(self.frame_advcomp, textvariable=self.gui.vid_format_var, width=8) - self.vid_format_entry.bind('', RightClicker) - - self.power_help_btn1 = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['fps_power']), bootstyle='secondary') - self.power_lbl1 = Label(self.frame_advcomp, text='Power (Importance)') - self.fps_power_lbl = Label(self.frame_advcomp, text='FPS:') - self.fps_power_entry = Entry(self.frame_advcomp, textvariable=self.gui.fps_power_var, width=8) - self.fps_power_entry.bind('', RightClicker) - self.res_power_lbl = Label(self.frame_advcomp, text='Res:') - self.res_power_entry = Entry(self.frame_advcomp, textvariable=self.gui.res_power_var, width=8) - self.res_power_entry.bind('', RightClicker) - - self.power_help_btn2 = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['fps_power']), bootstyle='secondary') - self.power_lbl2 = Label(self.frame_advcomp, text='Power (Importance)') - self.quality_power_lbl = Label(self.frame_advcomp, text='Quality:') - self.quality_power_entry = Entry(self.frame_advcomp, textvariable=self.gui.quality_power_var, width=8) - self.quality_power_entry.bind('', RightClicker) - self.color_power_lbl = Label(self.frame_advcomp, text='Color:') - self.color_power_entry = Entry(self.frame_advcomp, textvariable=self.gui.color_power_var, width=8) - self.color_power_entry.bind('', RightClicker) - - self.fake_vid_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['fake_vid']), bootstyle='secondary') - self.fake_vid_lbl = Label(self.frame_advcomp, text='Convert (faking) image to video') - self.fake_vid_cbox = Checkbutton(self.frame_advcomp, variable=self.gui.fake_vid_var, onvalue=True, offvalue=False, bootstyle='success-round-toggle') - - self.scale_filter_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['scale_filter']), bootstyle='secondary') - self.scale_filter_lbl = Label(self.frame_advcomp, text='Scale filter') - self.scale_filter_opt = OptionMenu(self.frame_advcomp, self.gui.scale_filter_var, self.gui.scale_filter_var.get(), 'nearest', 'bilinear', 'bicubic', 'lanczos', bootstyle='secondary') - - self.quantize_method_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['quantize_method']), bootstyle='secondary') - self.quantize_method_lbl = Label(self.frame_advcomp, text='Quantize method') - self.quantize_method_opt = OptionMenu(self.frame_advcomp, self.gui.quantize_method_var, self.gui.quantize_method_var.get(), 'imagequant', 'fastoctree', 'none', bootstyle='secondary') - - self.cache_dir_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['cache_dir']), bootstyle='secondary') - self.cache_dir_lbl = Label(self.frame_advcomp, text='Custom cache directory') - self.cache_dir_entry = Entry(self.frame_advcomp, textvariable=self.gui.cache_dir_var, width=30) - self.cache_dir_entry.bind('', RightClicker) - - self.default_emoji_help_btn = Button(self.frame_advcomp, text='?', width=1, command=lambda: cb_msg_block_adv_comp_win(self.gui.help['comp']['default_emoji']), bootstyle='secondary') - self.default_emoji_lbl = Label(self.frame_advcomp, text='Default emoji') - self.im = Image.new("RGBA", (32, 32), (255,255,255,0)) + self.fps_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["fps"]), + bootstyle="secondary", # type: ignore + ) + self.fps_lbl = Label(self.frame_advcomp, text="Output FPS") + self.fps_min_lbl = Label(self.frame_advcomp, text="Min:") + self.fps_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.fps_min_var, width=8 + ) + self.fps_min_entry.bind("", RightClicker) + self.fps_max_lbl = Label(self.frame_advcomp, text="Max:") + self.fps_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.fps_max_var, width=8 + ) + self.fps_max_entry.bind("", RightClicker) + self.fps_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.fps_disable_var, + command=self.cb_disable_fps, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.res_w_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["res"]), + bootstyle="secondary", # type: ignore + ) + self.res_w_lbl = Label(self.frame_advcomp, text="Output resolution (Width)") + self.res_w_min_lbl = Label(self.frame_advcomp, text="Min:") + self.res_w_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.res_w_min_var, width=8 + ) + self.res_w_min_entry.bind("", RightClicker) + self.res_w_max_lbl = Label(self.frame_advcomp, text="Max:") + self.res_w_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.res_w_max_var, width=8 + ) + self.res_w_max_entry.bind("", RightClicker) + self.res_w_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.res_w_disable_var, + command=self.cb_disable_res_w, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.res_h_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["res"]), + bootstyle="secondary", # type: ignore + ) + self.res_h_lbl = Label(self.frame_advcomp, text="Output resolution (Height)") + self.res_h_min_lbl = Label(self.frame_advcomp, text="Min:") + self.res_h_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.res_h_min_var, width=8 + ) + self.res_h_min_entry.bind("", RightClicker) + self.res_h_max_lbl = Label(self.frame_advcomp, text="Max:") + self.res_h_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.res_h_max_var, width=8 + ) + self.res_h_max_entry.bind("", RightClicker) + self.res_h_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.res_h_disable_var, + command=self.cb_disable_res_h, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.quality_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["quality"]), + bootstyle="secondary", # type: ignore + ) + self.quality_lbl = Label(self.frame_advcomp, text="Output quality (0-100)") + self.quality_min_lbl = Label(self.frame_advcomp, text="Min:") + self.quality_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.quality_min_var, width=8 + ) + self.quality_min_entry.bind("", RightClicker) + self.quality_max_lbl = Label(self.frame_advcomp, text="Max:") + self.quality_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.quality_max_var, width=8 + ) + self.quality_max_entry.bind("", RightClicker) + self.quality_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.quality_disable_var, + command=self.cb_disable_quality, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.color_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["color"]), + bootstyle="secondary", # type: ignore + ) + self.color_lbl = Label(self.frame_advcomp, text="Colors (0-256)") + self.color_min_lbl = Label(self.frame_advcomp, text="Min:") + self.color_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.color_min_var, width=8 + ) + self.color_min_entry.bind("", RightClicker) + self.color_max_lbl = Label(self.frame_advcomp, text="Max:") + self.color_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.color_max_var, width=8 + ) + self.color_max_entry.bind("", RightClicker) + self.color_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.color_disable_var, + command=self.cb_disable_color, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.duration_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["duration"] + ), + bootstyle="secondary", # type: ignore + ) + self.duration_lbl = Label(self.frame_advcomp, text="Duration (Miliseconds)") + self.duration_min_lbl = Label(self.frame_advcomp, text="Min:") + self.duration_min_entry = Entry( + self.frame_advcomp, textvariable=self.gui.duration_min_var, width=8 + ) + self.duration_min_entry.bind("", RightClicker) + self.duration_max_lbl = Label(self.frame_advcomp, text="Max:") + self.duration_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.duration_max_var, width=8 + ) + self.duration_max_entry.bind("", RightClicker) + self.duration_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.duration_disable_var, + command=self.cb_disable_duration, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.size_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["size"]), + bootstyle="secondary", # type: ignore + ) + self.size_lbl = Label(self.frame_advcomp, text="Maximum file size (bytes)") + self.img_size_max_lbl = Label(self.frame_advcomp, text="Img:") + self.img_size_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.img_size_max_var, width=8 + ) + self.img_size_max_entry.bind("", RightClicker) + self.vid_size_max_lbl = Label(self.frame_advcomp, text="Vid:") + self.vid_size_max_entry = Entry( + self.frame_advcomp, textvariable=self.gui.vid_size_max_var, width=8 + ) + self.vid_size_max_entry.bind("", RightClicker) + self.size_disable_cbox = Checkbutton( + self.frame_advcomp, + text="X", + variable=self.gui.size_disable_var, + command=self.cb_disable_size, + onvalue=True, + offvalue=False, + bootstyle="danger-round-toggle", # type: ignore + ) + + self.format_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win(self.gui.help["comp"]["format"]), + bootstyle="secondary", # type: ignore + ) + self.format_lbl = Label(self.frame_advcomp, text="File format") + self.img_format_lbl = Label(self.frame_advcomp, text="Img:") + self.img_format_entry = Entry( + self.frame_advcomp, textvariable=self.gui.img_format_var, width=8 + ) + self.img_format_entry.bind("", RightClicker) + self.vid_format_lbl = Label(self.frame_advcomp, text="Vid:") + self.vid_format_entry = Entry( + self.frame_advcomp, textvariable=self.gui.vid_format_var, width=8 + ) + self.vid_format_entry.bind("", RightClicker) + + self.power_help_btn1 = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["fps_power"] + ), + bootstyle="secondary", # type: ignore + ) + self.power_lbl1 = Label(self.frame_advcomp, text="Power (Importance)") + self.fps_power_lbl = Label(self.frame_advcomp, text="FPS:") + self.fps_power_entry = Entry( + self.frame_advcomp, textvariable=self.gui.fps_power_var, width=8 + ) + self.fps_power_entry.bind("", RightClicker) + self.res_power_lbl = Label(self.frame_advcomp, text="Res:") + self.res_power_entry = Entry( + self.frame_advcomp, textvariable=self.gui.res_power_var, width=8 + ) + self.res_power_entry.bind("", RightClicker) + + self.power_help_btn2 = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["fps_power"] + ), + bootstyle="secondary", # type: ignore + ) + self.power_lbl2 = Label(self.frame_advcomp, text="Power (Importance)") + self.quality_power_lbl = Label(self.frame_advcomp, text="Quality:") + self.quality_power_entry = Entry( + self.frame_advcomp, textvariable=self.gui.quality_power_var, width=8 + ) + self.quality_power_entry.bind("", RightClicker) + self.color_power_lbl = Label(self.frame_advcomp, text="Color:") + self.color_power_entry = Entry( + self.frame_advcomp, textvariable=self.gui.color_power_var, width=8 + ) + self.color_power_entry.bind("", RightClicker) + + self.fake_vid_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["fake_vid"] + ), + bootstyle="secondary", # type: ignore + ) + self.fake_vid_lbl = Label( + self.frame_advcomp, text="Convert (faking) image to video" + ) + self.fake_vid_cbox = Checkbutton( + self.frame_advcomp, + variable=self.gui.fake_vid_var, + onvalue=True, + offvalue=False, + bootstyle="success-round-toggle", # type: ignore + ) + + self.scale_filter_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["scale_filter"] + ), + bootstyle="secondary", # type: ignore + ) + self.scale_filter_lbl = Label(self.frame_advcomp, text="Scale filter") + self.scale_filter_opt = OptionMenu( + self.frame_advcomp, + self.gui.scale_filter_var, + self.gui.scale_filter_var.get(), + "nearest", + "bilinear", + "bicubic", + "lanczos", + bootstyle="secondary", # type: ignore + ) + + self.quantize_method_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["quantize_method"] + ), + bootstyle="secondary", # type: ignore + ) + self.quantize_method_lbl = Label(self.frame_advcomp, text="Quantize method") + self.quantize_method_opt = OptionMenu( + self.frame_advcomp, + self.gui.quantize_method_var, + self.gui.quantize_method_var.get(), + "imagequant", + "fastoctree", + "none", + bootstyle="secondary", # type: ignore + ) + + self.cache_dir_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["cache_dir"] + ), + bootstyle="secondary", # type: ignore + ) + self.cache_dir_lbl = Label(self.frame_advcomp, text="Custom cache directory") + self.cache_dir_entry = Entry( + self.frame_advcomp, textvariable=self.gui.cache_dir_var, width=30 + ) + self.cache_dir_entry.bind("", RightClicker) + + self.default_emoji_help_btn = Button( + self.frame_advcomp, + text="?", + width=1, + command=lambda: cb_msg_block_adv_comp_win( + self.gui.help["comp"]["default_emoji"] + ), + bootstyle="secondary", # type: ignore + ) + self.default_emoji_lbl = Label(self.frame_advcomp, text="Default emoji") + self.im = Image.new("RGBA", (32, 32), (255, 255, 255, 0)) self.ph_im = ImageTk.PhotoImage(self.im) self.default_emoji_dsp = Label(self.frame_advcomp, image=self.ph_im) - self.fps_help_btn.grid(column=0, row=2, sticky='w', padx=3, pady=3) - self.fps_lbl.grid(column=1, row=2, sticky='w', padx=3, pady=3) - self.fps_min_lbl.grid(column=2, row=2, sticky='w', padx=3, pady=3) - self.fps_min_entry.grid(column=3, row=2, sticky='nes', padx=3, pady=3) - self.fps_max_lbl.grid(column=4, row=2, sticky='w', padx=3, pady=3) - self.fps_max_entry.grid(column=5, row=2, sticky='nes', padx=3, pady=3) - self.fps_disable_cbox.grid(column=6, row=2, sticky='nes', padx=3, pady=3) - - self.res_w_help_btn.grid(column=0, row=3, sticky='w', padx=3, pady=3) - self.res_w_lbl.grid(column=1, row=3, sticky='w', padx=3, pady=3) - self.res_w_min_lbl.grid(column=2, row=3, sticky='w', padx=3, pady=3) - self.res_w_min_entry.grid(column=3, row=3, sticky='nes', padx=3, pady=3) - self.res_w_max_lbl.grid(column=4, row=3, sticky='w', padx=3, pady=3) - self.res_w_max_entry.grid(column=5, row=3, sticky='nes', padx=3, pady=3) - self.res_w_disable_cbox.grid(column=6, row=3, sticky='nes', padx=3, pady=3) - - self.res_h_help_btn.grid(column=0, row=4, sticky='w', padx=3, pady=3) - self.res_h_lbl.grid(column=1, row=4, sticky='w', padx=3, pady=3) - self.res_h_min_lbl.grid(column=2, row=4, sticky='w', padx=3, pady=3) - self.res_h_min_entry.grid(column=3, row=4, sticky='nes', padx=3, pady=3) - self.res_h_max_lbl.grid(column=4, row=4, sticky='w', padx=3, pady=3) - self.res_h_max_entry.grid(column=5, row=4, sticky='nes', padx=3, pady=3) - self.res_h_disable_cbox.grid(column=6, row=4, sticky='nes', padx=3, pady=3) - - self.quality_help_btn.grid(column=0, row=5, sticky='w', padx=3, pady=3) - self.quality_lbl.grid(column=1, row=5, sticky='w', padx=3, pady=3) - self.quality_min_lbl.grid(column=2, row=5, sticky='w', padx=3, pady=3) - self.quality_min_entry.grid(column=3, row=5, sticky='nes', padx=3, pady=3) - self.quality_max_lbl.grid(column=4, row=5, sticky='w', padx=3, pady=3) - self.quality_max_entry.grid(column=5, row=5, sticky='nes', padx=3, pady=3) - self.quality_disable_cbox.grid(column=6, row=5, sticky='nes', padx=3, pady=3) - - self.color_help_btn.grid(column=0, row=6, sticky='w', padx=3, pady=3) - self.color_lbl.grid(column=1, row=6, sticky='w', padx=3, pady=3) - self.color_min_lbl.grid(column=2, row=6, sticky='w', padx=3, pady=3) - self.color_min_entry.grid(column=3, row=6, sticky='nes', padx=3, pady=3) - self.color_max_lbl.grid(column=4, row=6, sticky='w', padx=3, pady=3) - self.color_max_entry.grid(column=5, row=6, sticky='nes', padx=3, pady=3) - self.color_disable_cbox.grid(column=6, row=6, sticky='nes', padx=3, pady=3) - - self.duration_help_btn.grid(column=0, row=7, sticky='w', padx=3, pady=3) - self.duration_lbl.grid(column=1, row=7, sticky='w', padx=3, pady=3) - self.duration_min_lbl.grid(column=2, row=7, sticky='w', padx=3, pady=3) - self.duration_min_entry.grid(column=3, row=7, sticky='nes', padx=3, pady=3) - self.duration_max_lbl.grid(column=4, row=7, sticky='w', padx=3, pady=3) - self.duration_max_entry.grid(column=5, row=7, sticky='nes', padx=3, pady=3) - self.duration_disable_cbox.grid(column=6, row=7, sticky='nes', padx=3, pady=3) - - self.size_help_btn.grid(column=0, row=8, sticky='w', padx=3, pady=3) - self.size_lbl.grid(column=1, row=8, sticky='w', padx=3, pady=3) - self.img_size_max_lbl.grid(column=2, row=8, sticky='w', padx=3, pady=3) - self.img_size_max_entry.grid(column=3, row=8, sticky='nes', padx=3, pady=3) - self.vid_size_max_lbl.grid(column=4, row=8, sticky='w', padx=3, pady=3) - self.vid_size_max_entry.grid(column=5, row=8, sticky='nes', padx=3, pady=3) - self.size_disable_cbox.grid(column=6, row=8, sticky='nes', padx=3, pady=3) - - self.format_help_btn.grid(column=0, row=9, sticky='w', padx=3, pady=3) - self.format_lbl.grid(column=1, row=9, sticky='w', padx=3, pady=3) - self.img_format_lbl.grid(column=2, row=9, sticky='w', padx=3, pady=3) - self.img_format_entry.grid(column=3, row=9, sticky='nes', padx=3, pady=3) - self.vid_format_lbl.grid(column=4, row=9, sticky='w', padx=3, pady=3) - self.vid_format_entry.grid(column=5, row=9, sticky='nes', padx=3, pady=3) - - self.power_help_btn1.grid(column=0, row=10, sticky='w', padx=3, pady=3) - self.power_lbl1.grid(column=1, row=10, sticky='w', padx=3, pady=3) - self.fps_power_lbl.grid(column=2, row=10, sticky='w', padx=3, pady=3) - self.fps_power_entry.grid(column=3, row=10, sticky='w', padx=3, pady=3) - self.res_power_lbl.grid(column=4, row=10, sticky='w', padx=3, pady=3) - self.res_power_entry.grid(column=5, row=10, sticky='w', padx=3, pady=3) - - self.power_help_btn2.grid(column=0, row=11, sticky='w', padx=3, pady=3) - self.power_lbl2.grid(column=1, row=11, sticky='w', padx=3, pady=3) - self.quality_power_lbl.grid(column=2, row=11, sticky='w', padx=3, pady=3) - self.quality_power_entry.grid(column=3, row=11, sticky='w', padx=3, pady=3) - self.color_power_lbl.grid(column=4, row=11, sticky='w', padx=3, pady=3) - self.color_power_entry.grid(column=5, row=11, sticky='w', padx=3, pady=3) - - self.fake_vid_help_btn.grid(column=0, row=12, sticky='w', padx=3, pady=3) - self.fake_vid_lbl.grid(column=1, row=12, sticky='w', padx=3, pady=3) - self.fake_vid_cbox.grid(column=6, row=12, sticky='nes', padx=3, pady=3) - - self.scale_filter_help_btn.grid(column=0, row=13, sticky='w', padx=3, pady=3) - self.scale_filter_lbl.grid(column=1, row=13, sticky='w', padx=3, pady=3) - self.scale_filter_opt.grid(column=2, row=13, columnspan=4, sticky='nes', padx=3, pady=3) - - self.quantize_method_help_btn.grid(column=0, row=14, sticky='w', padx=3, pady=3) - self.quantize_method_lbl.grid(column=1, row=14, sticky='w', padx=3, pady=3) - self.quantize_method_opt.grid(column=2, row=14, columnspan=4, sticky='nes', padx=3, pady=3) - - self.cache_dir_help_btn.grid(column=0, row=15, sticky='w', padx=3, pady=3) - self.cache_dir_lbl.grid(column=1, row=15, sticky='w', padx=3, pady=3) - self.cache_dir_entry.grid(column=2, row=15, columnspan=4, sticky='nes', padx=3, pady=3) - - self.default_emoji_help_btn.grid(column=0, row=16, sticky='w', padx=3, pady=3) - self.default_emoji_lbl.grid(column=1, row=16, sticky='w', padx=3, pady=3) - self.default_emoji_dsp.grid(column=6, row=16, sticky='nes', padx=3, pady=3) + self.fps_help_btn.grid(column=0, row=2, sticky="w", padx=3, pady=3) + self.fps_lbl.grid(column=1, row=2, sticky="w", padx=3, pady=3) + self.fps_min_lbl.grid(column=2, row=2, sticky="w", padx=3, pady=3) + self.fps_min_entry.grid(column=3, row=2, sticky="nes", padx=3, pady=3) + self.fps_max_lbl.grid(column=4, row=2, sticky="w", padx=3, pady=3) + self.fps_max_entry.grid(column=5, row=2, sticky="nes", padx=3, pady=3) + self.fps_disable_cbox.grid(column=6, row=2, sticky="nes", padx=3, pady=3) + + self.res_w_help_btn.grid(column=0, row=3, sticky="w", padx=3, pady=3) + self.res_w_lbl.grid(column=1, row=3, sticky="w", padx=3, pady=3) + self.res_w_min_lbl.grid(column=2, row=3, sticky="w", padx=3, pady=3) + self.res_w_min_entry.grid(column=3, row=3, sticky="nes", padx=3, pady=3) + self.res_w_max_lbl.grid(column=4, row=3, sticky="w", padx=3, pady=3) + self.res_w_max_entry.grid(column=5, row=3, sticky="nes", padx=3, pady=3) + self.res_w_disable_cbox.grid(column=6, row=3, sticky="nes", padx=3, pady=3) + + self.res_h_help_btn.grid(column=0, row=4, sticky="w", padx=3, pady=3) + self.res_h_lbl.grid(column=1, row=4, sticky="w", padx=3, pady=3) + self.res_h_min_lbl.grid(column=2, row=4, sticky="w", padx=3, pady=3) + self.res_h_min_entry.grid(column=3, row=4, sticky="nes", padx=3, pady=3) + self.res_h_max_lbl.grid(column=4, row=4, sticky="w", padx=3, pady=3) + self.res_h_max_entry.grid(column=5, row=4, sticky="nes", padx=3, pady=3) + self.res_h_disable_cbox.grid(column=6, row=4, sticky="nes", padx=3, pady=3) + + self.quality_help_btn.grid(column=0, row=5, sticky="w", padx=3, pady=3) + self.quality_lbl.grid(column=1, row=5, sticky="w", padx=3, pady=3) + self.quality_min_lbl.grid(column=2, row=5, sticky="w", padx=3, pady=3) + self.quality_min_entry.grid(column=3, row=5, sticky="nes", padx=3, pady=3) + self.quality_max_lbl.grid(column=4, row=5, sticky="w", padx=3, pady=3) + self.quality_max_entry.grid(column=5, row=5, sticky="nes", padx=3, pady=3) + self.quality_disable_cbox.grid(column=6, row=5, sticky="nes", padx=3, pady=3) + + self.color_help_btn.grid(column=0, row=6, sticky="w", padx=3, pady=3) + self.color_lbl.grid(column=1, row=6, sticky="w", padx=3, pady=3) + self.color_min_lbl.grid(column=2, row=6, sticky="w", padx=3, pady=3) + self.color_min_entry.grid(column=3, row=6, sticky="nes", padx=3, pady=3) + self.color_max_lbl.grid(column=4, row=6, sticky="w", padx=3, pady=3) + self.color_max_entry.grid(column=5, row=6, sticky="nes", padx=3, pady=3) + self.color_disable_cbox.grid(column=6, row=6, sticky="nes", padx=3, pady=3) + + self.duration_help_btn.grid(column=0, row=7, sticky="w", padx=3, pady=3) + self.duration_lbl.grid(column=1, row=7, sticky="w", padx=3, pady=3) + self.duration_min_lbl.grid(column=2, row=7, sticky="w", padx=3, pady=3) + self.duration_min_entry.grid(column=3, row=7, sticky="nes", padx=3, pady=3) + self.duration_max_lbl.grid(column=4, row=7, sticky="w", padx=3, pady=3) + self.duration_max_entry.grid(column=5, row=7, sticky="nes", padx=3, pady=3) + self.duration_disable_cbox.grid(column=6, row=7, sticky="nes", padx=3, pady=3) + + self.size_help_btn.grid(column=0, row=8, sticky="w", padx=3, pady=3) + self.size_lbl.grid(column=1, row=8, sticky="w", padx=3, pady=3) + self.img_size_max_lbl.grid(column=2, row=8, sticky="w", padx=3, pady=3) + self.img_size_max_entry.grid(column=3, row=8, sticky="nes", padx=3, pady=3) + self.vid_size_max_lbl.grid(column=4, row=8, sticky="w", padx=3, pady=3) + self.vid_size_max_entry.grid(column=5, row=8, sticky="nes", padx=3, pady=3) + self.size_disable_cbox.grid(column=6, row=8, sticky="nes", padx=3, pady=3) + + self.format_help_btn.grid(column=0, row=9, sticky="w", padx=3, pady=3) + self.format_lbl.grid(column=1, row=9, sticky="w", padx=3, pady=3) + self.img_format_lbl.grid(column=2, row=9, sticky="w", padx=3, pady=3) + self.img_format_entry.grid(column=3, row=9, sticky="nes", padx=3, pady=3) + self.vid_format_lbl.grid(column=4, row=9, sticky="w", padx=3, pady=3) + self.vid_format_entry.grid(column=5, row=9, sticky="nes", padx=3, pady=3) + + self.power_help_btn1.grid(column=0, row=10, sticky="w", padx=3, pady=3) + self.power_lbl1.grid(column=1, row=10, sticky="w", padx=3, pady=3) + self.fps_power_lbl.grid(column=2, row=10, sticky="w", padx=3, pady=3) + self.fps_power_entry.grid(column=3, row=10, sticky="w", padx=3, pady=3) + self.res_power_lbl.grid(column=4, row=10, sticky="w", padx=3, pady=3) + self.res_power_entry.grid(column=5, row=10, sticky="w", padx=3, pady=3) + + self.power_help_btn2.grid(column=0, row=11, sticky="w", padx=3, pady=3) + self.power_lbl2.grid(column=1, row=11, sticky="w", padx=3, pady=3) + self.quality_power_lbl.grid(column=2, row=11, sticky="w", padx=3, pady=3) + self.quality_power_entry.grid(column=3, row=11, sticky="w", padx=3, pady=3) + self.color_power_lbl.grid(column=4, row=11, sticky="w", padx=3, pady=3) + self.color_power_entry.grid(column=5, row=11, sticky="w", padx=3, pady=3) + + self.fake_vid_help_btn.grid(column=0, row=12, sticky="w", padx=3, pady=3) + self.fake_vid_lbl.grid(column=1, row=12, sticky="w", padx=3, pady=3) + self.fake_vid_cbox.grid(column=6, row=12, sticky="nes", padx=3, pady=3) + + self.scale_filter_help_btn.grid(column=0, row=13, sticky="w", padx=3, pady=3) + self.scale_filter_lbl.grid(column=1, row=13, sticky="w", padx=3, pady=3) + self.scale_filter_opt.grid( + column=2, row=13, columnspan=4, sticky="nes", padx=3, pady=3 + ) + + self.quantize_method_help_btn.grid(column=0, row=14, sticky="w", padx=3, pady=3) + self.quantize_method_lbl.grid(column=1, row=14, sticky="w", padx=3, pady=3) + self.quantize_method_opt.grid( + column=2, row=14, columnspan=4, sticky="nes", padx=3, pady=3 + ) + + self.cache_dir_help_btn.grid(column=0, row=15, sticky="w", padx=3, pady=3) + self.cache_dir_lbl.grid(column=1, row=15, sticky="w", padx=3, pady=3) + self.cache_dir_entry.grid( + column=2, row=15, columnspan=4, sticky="nes", padx=3, pady=3 + ) + + self.default_emoji_help_btn.grid(column=0, row=16, sticky="w", padx=3, pady=3) + self.default_emoji_lbl.grid(column=1, row=16, sticky="w", padx=3, pady=3) + self.default_emoji_dsp.grid(column=6, row=16, sticky="nes", padx=3, pady=3) # https://stackoverflow.com/questions/43731784/tkinter-canvas-scrollbar-with-grid # Create a frame for the canvas with non-zero row&column weights @@ -257,38 +505,55 @@ def __init__(self, *args, **kwargs): # Set grid_propagate to False to allow buttons resizing later self.frame_emoji_canvas.grid_propagate(False) - self.frame_advcomp.grid(column=0, row=0, sticky='news', padx=3, pady=3) - self.frame_emoji_search.grid(column=0, row=1, sticky='news', padx=3, pady=3) - self.frame_emoji_canvas.grid(column=0, row=2, sticky='news', padx=3, pady=3) - - self.categories_lbl = Label(self.frame_emoji_search, text='Category', width=15, justify='left', anchor='w') + self.frame_advcomp.grid(column=0, row=0, sticky="news", padx=3, pady=3) + self.frame_emoji_search.grid(column=0, row=1, sticky="news", padx=3, pady=3) + self.frame_emoji_canvas.grid(column=0, row=2, sticky="news", padx=3, pady=3) + + self.categories_lbl = Label( + self.frame_emoji_search, + text="Category", + width=15, + justify="left", + anchor="w", + ) self.categories_var = StringVar(self.scrollable_frame) - self.categories_var.set('Smileys & Emotion') - self.categories_opt = OptionMenu(self.frame_emoji_search, self.categories_var, 'Smileys & Emotion', *self.categories, command=self.render_emoji_list, bootstyle='secondary') + self.categories_var.set("Smileys & Emotion") + self.categories_opt = OptionMenu( + self.frame_emoji_search, + self.categories_var, + "Smileys & Emotion", + *self.categories, + command=self.render_emoji_list, + bootstyle="secondary", # type: ignore + ) self.categories_opt.config(width=30) - self.search_lbl = Label(self.frame_emoji_search, text='Search') + self.search_lbl = Label(self.frame_emoji_search, text="Search") self.search_var = StringVar(self.frame_emoji_search) self.search_var.trace_add("write", self.render_emoji_list) self.search_entry = Entry(self.frame_emoji_search, textvariable=self.search_var) - self.search_entry.bind('', RightClicker) + self.search_entry.bind("", RightClicker) - self.categories_lbl.grid(column=0, row=0, sticky='nsw', padx=3, pady=3) - self.categories_opt.grid(column=1, row=0, sticky='news', padx=3, pady=3) - self.search_lbl.grid(column=0, row=1, sticky='nsw', padx=3, pady=3) - self.search_entry.grid(column=1, row=1, sticky='news', padx=3, pady=3) + self.categories_lbl.grid(column=0, row=0, sticky="nsw", padx=3, pady=3) + self.categories_opt.grid(column=1, row=0, sticky="news", padx=3, pady=3) + self.search_lbl.grid(column=0, row=1, sticky="nsw", padx=3, pady=3) + self.search_entry.grid(column=1, row=1, sticky="news", padx=3, pady=3) # Add a canvas in frame_emoji_canvas self.emoji_canvas = Canvas(self.frame_emoji_canvas) - self.emoji_canvas.grid(row=0, column=0, sticky='news') + self.emoji_canvas.grid(row=0, column=0, sticky="news") # Link a scrollbar to the canvas - self.vsb = Scrollbar(self.frame_emoji_canvas, orient="vertical", command=self.emoji_canvas.yview) - self.vsb.grid(row=0, column=1, sticky='ns') + self.vsb = Scrollbar( + self.frame_emoji_canvas, + orient="vertical", + command=self.emoji_canvas.yview, # type: ignore + ) + self.vsb.grid(row=0, column=1, sticky="ns") self.emoji_canvas.configure(yscrollcommand=self.vsb.set) self.frame_buttons = Frame(self.emoji_canvas) - self.emoji_canvas.create_window((0, 0), window=self.frame_buttons, anchor='nw') + self.emoji_canvas.create_window((0, 0), window=self.frame_buttons, anchor="nw") self.render_emoji_list() @@ -304,101 +569,112 @@ def __init__(self, *args, **kwargs): self.cb_disable_fake_vid() GUIUtils.finalize_window(self) - - def cb_disable_fps(self, *args): - if self.gui.fps_disable_var.get() == True: - state = 'disabled' + + def cb_disable_fps(self, *args: Any): + if self.gui.fps_disable_var.get() is True: + state = "disabled" else: - state = 'normal' + state = "normal" self.fps_min_entry.config(state=state) self.fps_max_entry.config(state=state) - def cb_disable_res_w(self, *args): - if self.gui.res_w_disable_var.get() == True: - state = 'disabled' + def cb_disable_res_w(self, *args: Any): + if self.gui.res_w_disable_var.get() is True: + state = "disabled" else: - state = 'normal' + state = "normal" self.res_w_min_entry.config(state=state) self.res_w_max_entry.config(state=state) - def cb_disable_res_h(self, *args): - if self.gui.res_h_disable_var.get() == True: - state = 'disabled' + def cb_disable_res_h(self, *args: Any): + if self.gui.res_h_disable_var.get() is True: + state = "disabled" else: - state = 'normal' + state = "normal" self.res_h_min_entry.config(state=state) self.res_h_max_entry.config(state=state) - def cb_disable_quality(self, *args): - if self.gui.quality_disable_var.get() == True: - state = 'disabled' + def cb_disable_quality(self, *args: Any): + if self.gui.quality_disable_var.get() is True: + state = "disabled" else: - state = 'normal' + state = "normal" self.quality_min_entry.config(state=state) self.quality_max_entry.config(state=state) - def cb_disable_color(self, *args): - if self.gui.color_disable_var.get() == True: - state = 'disabled' + def cb_disable_color(self, *args: Any): + if self.gui.color_disable_var.get() is True: + state = "disabled" else: - state = 'normal' + state = "normal" self.color_min_entry.config(state=state) self.color_max_entry.config(state=state) - def cb_disable_duration(self, *args): - if self.gui.duration_disable_var.get() == True or self.gui.comp_preset_var.get() == 'auto': - state = 'disabled' + def cb_disable_duration(self, *args: Any): + if ( + self.gui.duration_disable_var.get() is True + or self.gui.comp_preset_var.get() == "auto" + ): + state = "disabled" else: - state = 'normal' + state = "normal" self.duration_min_entry.config(state=state) self.duration_max_entry.config(state=state) - def cb_disable_size(self, *args): - if self.gui.size_disable_var.get() == True or self.gui.comp_preset_var.get() == 'auto': - state = 'disabled' + def cb_disable_size(self, *args: Any): + if ( + self.gui.size_disable_var.get() is True + or self.gui.comp_preset_var.get() == "auto" + ): + state = "disabled" else: - state = 'normal' + state = "normal" self.img_size_max_entry.config(state=state) self.vid_size_max_entry.config(state=state) - - def cb_disable_format(self, *args): - if self.gui.comp_preset_var.get() == 'auto': - state = 'disabled' + + def cb_disable_format(self, *args: Any): + if self.gui.comp_preset_var.get() == "auto": + state = "disabled" else: - state = 'normal' + state = "normal" self.img_format_entry.config(state=state) self.vid_format_entry.config(state=state) - - def cb_disable_fake_vid(self, *args): - if self.gui.comp_preset_var.get() == 'auto': - state = 'disabled' + + def cb_disable_fake_vid(self, *args: Any): + if self.gui.comp_preset_var.get() == "auto": + state = "disabled" else: - state = 'normal' + state = "normal" self.fake_vid_cbox.config(state=state) - + def set_emoji_btn(self): - self.im = Image.new("RGBA", (128, 128), (255,255,255,0)) - ImageDraw.Draw(self.im).text((0, 0), self.gui.default_emoji_var.get(), embedded_color=True, font=self.gui.emoji_font) + self.im = Image.new("RGBA", (128, 128), (255, 255, 255, 0)) + ImageDraw.Draw(self.im).text( # type: ignore + (0, 0), + self.gui.default_emoji_var.get(), + embedded_color=True, + font=self.gui.emoji_font, + ) # type: ignore self.im = self.im.resize((32, 32)) self.ph_im = ImageTk.PhotoImage(self.im) self.default_emoji_dsp.config(image=self.ph_im) - - def render_emoji_list(self, *args): + + def render_emoji_list(self, *args: Any): category = self.categories_var.get() - + for emoji_btn, ph_im in self.emoji_btns: emoji_btn.destroy() del ph_im - + column = 0 row = 0 @@ -406,10 +682,10 @@ def render_emoji_list(self, *args): for entry in self.gui.emoji_list: # Filtering search_term = self.search_var.get().lower() - emoji = entry['emoji'] - keywords = entry['aliases'] + entry['tags'] + [emoji] - if search_term == '': - if entry['category'] != category: + emoji = entry["emoji"] + keywords = entry["aliases"] + entry["tags"] + [emoji] + if search_term == "": + if entry["category"] != category: continue else: ok = False @@ -421,15 +697,21 @@ def render_emoji_list(self, *args): if search_term in i: ok = True - if ok == False: + if ok is False: continue - - im = Image.new("RGBA", (196, 196), (255,255,255,0)) - ImageDraw.Draw(im).text((16, 16), emoji, embedded_color=True, font=self.gui.emoji_font) + + im = Image.new("RGBA", (196, 196), (255, 255, 255, 0)) + ImageDraw.Draw(im).text( # type: ignore + (16, 16), emoji, embedded_color=True, font=self.gui.emoji_font + ) # type: ignore im = im.resize((32, 32)) ph_im = ImageTk.PhotoImage(im) - button = Button(self.frame_buttons, command=lambda i=emoji: self.cb_set_emoji(i), bootstyle='dark') + button = Button( + self.frame_buttons, + command=lambda i=emoji: self.cb_set_emoji(i), # type: ignore + bootstyle="dark", # type: ignore + ) button.config(image=ph_im) button.grid(column=column, row=row) @@ -440,34 +722,42 @@ def render_emoji_list(self, *args): row += 1 self.emoji_btns.append((button, ph_im)) - + # Update buttons frames idle tasks to let tkinter calculate buttons sizes self.frame_buttons.update_idletasks() # Resize the canvas frame to show specified number of buttons and the scrollbar if len(self.emoji_btns) > 0: - in_view_columns_width = self.emoji_btns[0][0].winfo_width() * self.emoji_column_per_row - in_view_rows_height = self.emoji_btns[0][0].winfo_height() * self.emoji_visible_rows - self.frame_emoji_canvas.config(width=in_view_columns_width + self.vsb.winfo_width(), - height=in_view_rows_height) + in_view_columns_width = ( + self.emoji_btns[0][0].winfo_width() * self.emoji_column_per_row + ) + in_view_rows_height = ( + self.emoji_btns[0][0].winfo_height() * self.emoji_visible_rows + ) + self.frame_emoji_canvas.config( + width=in_view_columns_width + self.vsb.winfo_width(), + height=in_view_rows_height, + ) # Set the canvas scrolling region self.emoji_canvas.config(scrollregion=self.emoji_canvas.bbox("all")) # https://stackoverflow.com/questions/17355902/tkinter-binding-mousewheel-to-scrollbar - self.emoji_canvas.bind('', self.cb_bound_to_mousewheel) - self.emoji_canvas.bind('', self.cb_unbound_to_mousewheel) - - def cb_bound_to_mousewheel(self, event: Event): + self.emoji_canvas.bind("", self.cb_bound_to_mousewheel) + self.emoji_canvas.bind("", self.cb_unbound_to_mousewheel) + + def cb_bound_to_mousewheel(self, event: Any): for i in self.mousewheel: self.emoji_canvas.bind_all(i, self.cb_on_mousewheel) - - def cb_unbound_to_mousewheel(self, event: Event): + + def cb_unbound_to_mousewheel(self, event: Any): for i in self.mousewheel: self.emoji_canvas.unbind_all(i) - - def cb_on_mousewheel(self, event: Event): - self.emoji_canvas.yview_scroll(int(-1*(event.delta/self.delta_divide)), "units") + + def cb_on_mousewheel(self, event: Any): + self.emoji_canvas.yview_scroll( + int(-1 * (event.delta / self.delta_divide)), "units" + ) # type: ignore def cb_set_emoji(self, emoji: str): self.gui.default_emoji_var.set(emoji) diff --git a/src/sticker_convert/gui_components/windows/base_window.py b/src/sticker_convert/gui_components/windows/base_window.py index 8eb224c..6159a7e 100644 --- a/src/sticker_convert/gui_components/windows/base_window.py +++ b/src/sticker_convert/gui_components/windows/base_window.py @@ -5,26 +5,26 @@ from ttkbootstrap import Toplevel # type: ignore if TYPE_CHECKING: - from sticker_convert.gui import GUI # type: ignore + from sticker_convert.gui import GUI # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore +from sticker_convert.gui_components.gui_utils import GUIUtils class BaseWindow(Toplevel): def __init__(self, gui: "GUI"): - super(BaseWindow, self).__init__(alpha=0) + super(BaseWindow, self).__init__(alpha=0) # type: ignore self.gui = gui - + GUIUtils.set_icon(self) - if platform.system() == 'Windows': - self.mousewheel = ('',) + if platform.system() == "Windows": + self.mousewheel = ("",) self.delta_divide = 120 - elif platform.system() == 'Darwin': - self.mousewheel = ('',) + elif platform.system() == "Darwin": + self.mousewheel = ("",) self.delta_divide = 1 else: - self.mousewheel = ('', '') + self.mousewheel = ("", "") self.delta_divide = 120 ( @@ -33,6 +33,5 @@ def __init__(self, gui: "GUI"): self.canvas, self.x_scrollbar, self.y_scrollbar, - self.scrollable_frame + self.scrollable_frame, ) = GUIUtils.create_scrollable_frame(self) - \ No newline at end of file diff --git a/src/sticker_convert/gui_components/windows/kakao_get_auth_window.py b/src/sticker_convert/gui_components/windows/kakao_get_auth_window.py index 8786ac9..0815bc3 100644 --- a/src/sticker_convert/gui_components/windows/kakao_get_auth_window.py +++ b/src/sticker_convert/gui_components/windows/kakao_get_auth_window.py @@ -1,101 +1,186 @@ #!/usr/bin/env python3 from functools import partial from threading import Thread +from typing import Any -from ttkbootstrap import (Button, Entry, Frame, Label, # type: ignore - LabelFrame) +from ttkbootstrap import Button, Entry, Frame, Label, LabelFrame # type: ignore -from sticker_convert.gui_components.frames.right_clicker import RightClicker # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore -from sticker_convert.gui_components.windows.base_window import BaseWindow # type: ignore -from sticker_convert.job_option import CredOption -from sticker_convert.utils.auth.get_kakao_auth import GetKakaoAuth # type: ignore +from sticker_convert.gui_components.frames.right_clicker import RightClicker +from sticker_convert.gui_components.gui_utils import GUIUtils +from sticker_convert.gui_components.windows.base_window import BaseWindow +from sticker_convert.utils.auth.get_kakao_auth import GetKakaoAuth class KakaoGetAuthWindow(BaseWindow): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(KakaoGetAuthWindow, self).__init__(*args, **kwargs) - self.title('Get Kakao auth_token') - + self.title("Get Kakao auth_token") + self.cb_msg_block_kakao = partial(self.gui.cb_msg_block, parent=self) self.cb_ask_str_kakao = partial(self.gui.cb_ask_str, parent=self) - self.frame_login_info = LabelFrame(self.scrollable_frame, text='Kakao login info') + self.frame_login_info = LabelFrame( + self.scrollable_frame, text="Kakao login info" + ) self.frame_login_btn = Frame(self.scrollable_frame) - self.frame_login_info.grid(column=0, row=0, sticky='news', padx=3, pady=3) - self.frame_login_btn.grid(column=0, row=1, sticky='news', padx=3, pady=3) + self.frame_login_info.grid(column=0, row=0, sticky="news", padx=3, pady=3) + self.frame_login_btn.grid(column=0, row=1, sticky="news", padx=3, pady=3) # Login info frame - self.explanation1_lbl = Label(self.frame_login_info, text='This will simulate login to Android Kakao app', justify='left', anchor='w') - self.explanation2_lbl = Label(self.frame_login_info, text='You will send / receive verification code via SMS', justify='left', anchor='w') - self.explanation3_lbl = Label(self.frame_login_info, text='You maybe logged out of existing device', justify='left', anchor='w') - - self.kakao_username_help_btn = Button(self.frame_login_info, text='?', width=1, command=lambda: self.cb_msg_block_kakao(self.gui.help['cred']['kakao_username']), bootstyle='secondary') - self.kakao_username_lbl = Label(self.frame_login_info, text='Username', width=18, justify='left', anchor='w') - self.kakao_username_entry = Entry(self.frame_login_info, textvariable=self.gui.kakao_username_var, width=30) - self.kakao_username_entry.bind('', RightClicker) - - self.kakao_password_help_btn = Button(self.frame_login_info, text='?', width=1, command=lambda: self.cb_msg_block_kakao(self.gui.help['cred']['kakao_password']), bootstyle='secondary') - self.kakao_password_lbl = Label(self.frame_login_info, text='Password', justify='left', anchor='w') - self.kakao_password_entry = Entry(self.frame_login_info, textvariable=self.gui.kakao_password_var, width=30) - self.kakao_password_entry.bind('', RightClicker) - - self.kakao_country_code_help_btn = Button(self.frame_login_info, text='?', width=1, command=lambda: self.cb_msg_block_kakao(self.gui.help['cred']['kakao_country_code']), bootstyle='secondary') - self.kakao_country_code_lbl = Label(self.frame_login_info, text='Country code', justify='left', anchor='w') - self.kakao_country_code_entry = Entry(self.frame_login_info, textvariable=self.gui.kakao_country_code_var, width=30) - self.kakao_country_code_entry.bind('', RightClicker) - - self.kakao_phone_number_help_btn = Button(self.frame_login_info, text='?', width=1, command=lambda: self.cb_msg_block_kakao(self.gui.help['cred']['kakao_phone_number']), bootstyle='secondary') - self.kakao_phone_number_lbl = Label(self.frame_login_info, text='Phone number', justify='left', anchor='w') - self.kakao_phone_number_entry = Entry(self.frame_login_info, textvariable=self.gui.kakao_phone_number_var, width=30) - self.kakao_phone_number_entry.bind('', RightClicker) - - self.explanation1_lbl.grid(column=0, row=0, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation2_lbl.grid(column=0, row=1, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation3_lbl.grid(column=0, row=2, columnspan=3, sticky='w', padx=3, pady=3) - - self.kakao_username_help_btn.grid(column=0, row=3, sticky='w', padx=3, pady=3) - self.kakao_username_lbl.grid(column=1, row=3, sticky='w', padx=3, pady=3) - self.kakao_username_entry.grid(column=2, row=3, sticky='w', padx=3, pady=3) - - self.kakao_password_help_btn.grid(column=0, row=4, sticky='w', padx=3, pady=3) - self.kakao_password_lbl.grid(column=1, row=4, sticky='w', padx=3, pady=3) - self.kakao_password_entry.grid(column=2, row=4, sticky='w', padx=3, pady=3) - - self.kakao_country_code_help_btn.grid(column=0, row=5, sticky='w', padx=3, pady=3) - self.kakao_country_code_lbl.grid(column=1, row=5, sticky='w', padx=3, pady=3) - self.kakao_country_code_entry.grid(column=2, row=5, sticky='w', padx=3, pady=3) - - self.kakao_phone_number_help_btn.grid(column=0, row=6, sticky='w', padx=3, pady=3) - self.kakao_phone_number_lbl.grid(column=1, row=6, sticky='w', padx=3, pady=3) - self.kakao_phone_number_entry.grid(column=2, row=6, sticky='w', padx=3, pady=3) + self.explanation1_lbl = Label( + self.frame_login_info, + text="This will simulate login to Android Kakao app", + justify="left", + anchor="w", + ) + self.explanation2_lbl = Label( + self.frame_login_info, + text="You will send / receive verification code via SMS", + justify="left", + anchor="w", + ) + self.explanation3_lbl = Label( + self.frame_login_info, + text="You maybe logged out of existing device", + justify="left", + anchor="w", + ) + + self.kakao_username_help_btn = Button( + self.frame_login_info, + text="?", + width=1, + command=lambda: self.cb_msg_block_kakao( + self.gui.help["cred"]["kakao_username"] + ), + bootstyle="secondary", # type: ignore + ) + self.kakao_username_lbl = Label( + self.frame_login_info, text="Username", width=18, justify="left", anchor="w" + ) + self.kakao_username_entry = Entry( + self.frame_login_info, textvariable=self.gui.kakao_username_var, width=30 + ) + self.kakao_username_entry.bind("", RightClicker) + + self.kakao_password_help_btn = Button( + self.frame_login_info, + text="?", + width=1, + command=lambda: self.cb_msg_block_kakao( + self.gui.help["cred"]["kakao_password"] + ), + bootstyle="secondary", # type: ignore + ) + self.kakao_password_lbl = Label( + self.frame_login_info, text="Password", justify="left", anchor="w" + ) + self.kakao_password_entry = Entry( + self.frame_login_info, textvariable=self.gui.kakao_password_var, width=30 + ) + self.kakao_password_entry.bind("", RightClicker) + + self.kakao_country_code_help_btn = Button( + self.frame_login_info, + text="?", + width=1, + command=lambda: self.cb_msg_block_kakao( + self.gui.help["cred"]["kakao_country_code"] + ), + bootstyle="secondary", # type: ignore + ) + self.kakao_country_code_lbl = Label( + self.frame_login_info, text="Country code", justify="left", anchor="w" + ) + self.kakao_country_code_entry = Entry( + self.frame_login_info, + textvariable=self.gui.kakao_country_code_var, + width=30, + ) + self.kakao_country_code_entry.bind("", RightClicker) + + self.kakao_phone_number_help_btn = Button( + self.frame_login_info, + text="?", + width=1, + command=lambda: self.cb_msg_block_kakao( + self.gui.help["cred"]["kakao_phone_number"] + ), + bootstyle="secondary", # type: ignore + ) + self.kakao_phone_number_lbl = Label( + self.frame_login_info, text="Phone number", justify="left", anchor="w" + ) + self.kakao_phone_number_entry = Entry( + self.frame_login_info, + textvariable=self.gui.kakao_phone_number_var, + width=30, + ) + self.kakao_phone_number_entry.bind("", RightClicker) + + self.explanation1_lbl.grid( + column=0, row=0, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation2_lbl.grid( + column=0, row=1, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation3_lbl.grid( + column=0, row=2, columnspan=3, sticky="w", padx=3, pady=3 + ) + + self.kakao_username_help_btn.grid(column=0, row=3, sticky="w", padx=3, pady=3) + self.kakao_username_lbl.grid(column=1, row=3, sticky="w", padx=3, pady=3) + self.kakao_username_entry.grid(column=2, row=3, sticky="w", padx=3, pady=3) + + self.kakao_password_help_btn.grid(column=0, row=4, sticky="w", padx=3, pady=3) + self.kakao_password_lbl.grid(column=1, row=4, sticky="w", padx=3, pady=3) + self.kakao_password_entry.grid(column=2, row=4, sticky="w", padx=3, pady=3) + + self.kakao_country_code_help_btn.grid( + column=0, row=5, sticky="w", padx=3, pady=3 + ) + self.kakao_country_code_lbl.grid(column=1, row=5, sticky="w", padx=3, pady=3) + self.kakao_country_code_entry.grid(column=2, row=5, sticky="w", padx=3, pady=3) + + self.kakao_phone_number_help_btn.grid( + column=0, row=6, sticky="w", padx=3, pady=3 + ) + self.kakao_phone_number_lbl.grid(column=1, row=6, sticky="w", padx=3, pady=3) + self.kakao_phone_number_entry.grid(column=2, row=6, sticky="w", padx=3, pady=3) # Login button frame - self.login_btn = Button(self.frame_login_btn, text='Login and get auth_token', command=self.cb_login) + self.login_btn = Button( + self.frame_login_btn, text="Login and get auth_token", command=self.cb_login + ) self.login_btn.pack() GUIUtils.finalize_window(self) - + def cb_login(self): Thread(target=self.cb_login_thread, daemon=True).start() - - def cb_login_thread(self, *args): + + def cb_login_thread(self, *args: Any): self.gui.save_creds() - m = GetKakaoAuth(opt_cred=CredOption(self.gui.creds), cb_msg=self.gui.cb_msg, cb_msg_block=self.cb_msg_block_kakao, cb_ask_str=self.cb_ask_str_kakao) + m = GetKakaoAuth( + opt_cred=self.gui.get_opt_cred(), + cb_msg=self.gui.cb_msg, + cb_msg_block=self.cb_msg_block_kakao, + cb_ask_str=self.cb_ask_str_kakao, + ) auth_token = m.get_cred() if auth_token: - if not self.gui.creds.get('kakao'): - self.gui.creds['kakao'] = {} - self.gui.creds['kakao']['auth_token'] = auth_token + if not self.gui.creds.get("kakao"): + self.gui.creds["kakao"] = {} + self.gui.creds["kakao"]["auth_token"] = auth_token self.gui.kakao_auth_token_var.set(auth_token) - - self.cb_msg_block_kakao(f'Got auth_token successfully: {auth_token}') + + self.cb_msg_block_kakao(f"Got auth_token successfully: {auth_token}") self.gui.save_creds() self.gui.highlight_fields() else: - self.cb_msg_block_kakao('Failed to get auth_token') + self.cb_msg_block_kakao("Failed to get auth_token") diff --git a/src/sticker_convert/gui_components/windows/line_get_auth_window.py b/src/sticker_convert/gui_components/windows/line_get_auth_window.py index c9e585d..66d2969 100644 --- a/src/sticker_convert/gui_components/windows/line_get_auth_window.py +++ b/src/sticker_convert/gui_components/windows/line_get_auth_window.py @@ -2,70 +2,101 @@ import webbrowser from functools import partial from threading import Thread +from typing import Any from ttkbootstrap import Button, Frame, Label # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore -from sticker_convert.gui_components.windows.base_window import BaseWindow # type: ignore -from sticker_convert.utils.auth.get_line_auth import GetLineAuth # type: ignore +from sticker_convert.gui_components.gui_utils import GUIUtils +from sticker_convert.gui_components.windows.base_window import BaseWindow +from sticker_convert.utils.auth.get_line_auth import GetLineAuth class LineGetAuthWindow(BaseWindow): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(LineGetAuthWindow, self).__init__(*args, **kwargs) - self.title('Get Line cookie') - + self.title("Get Line cookie") + self.cb_msg_block_line = partial(self.gui.cb_msg_block, parent=self) self.frame_info = Frame(self.scrollable_frame) self.frame_btn = Frame(self.scrollable_frame) - self.frame_info.grid(column=0, row=0, sticky='news', padx=3, pady=3) - self.frame_btn.grid(column=0, row=1, sticky='news', padx=3, pady=3) + self.frame_info.grid(column=0, row=0, sticky="news", padx=3, pady=3) + self.frame_btn.grid(column=0, row=1, sticky="news", padx=3, pady=3) # Info frame - self.explanation1_lbl = Label(self.frame_info, text='Line cookies are required to create custom message stickers', justify='left', anchor='w') - self.explanation2_lbl = Label(self.frame_info, text='Please open web browser and login to Line', justify='left', anchor='w') - self.explanation3_lbl = Label(self.frame_info, text='After that, press "Get cookies"', justify='left', anchor='w') + self.explanation1_lbl = Label( + self.frame_info, + text="Line cookies are required to create custom message stickers", + justify="left", + anchor="w", + ) + self.explanation2_lbl = Label( + self.frame_info, + text="Please open web browser and login to Line", + justify="left", + anchor="w", + ) + self.explanation3_lbl = Label( + self.frame_info, + text='After that, press "Get cookies"', + justify="left", + anchor="w", + ) - self.explanation1_lbl.grid(column=0, row=0, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation2_lbl.grid(column=0, row=1, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation3_lbl.grid(column=0, row=2, columnspan=3, sticky='w', padx=3, pady=3) + self.explanation1_lbl.grid( + column=0, row=0, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation2_lbl.grid( + column=0, row=1, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation3_lbl.grid( + column=0, row=2, columnspan=3, sticky="w", padx=3, pady=3 + ) # Buttons frame - self.open_browser_btn = Button(self.frame_btn, text='Open browser', command=self.cb_open_browser) - self.get_cookies_btn = Button(self.frame_btn, text='Get cookies', command=self.cb_get_cookies) + self.open_browser_btn = Button( + self.frame_btn, text="Open browser", command=self.cb_open_browser + ) + self.get_cookies_btn = Button( + self.frame_btn, text="Get cookies", command=self.cb_get_cookies + ) self.open_browser_btn.pack() self.get_cookies_btn.pack() GUIUtils.finalize_window(self) - + def cb_open_browser(self): - line_login_site = 'https://store.line.me/login' + line_login_site = "https://store.line.me/login" success = webbrowser.open(line_login_site) if not success: - self.gui.cb_ask_str('Cannot open web browser for you. Install web browser and open:', initialvalue=line_login_site) - + self.gui.cb_ask_str( + "Cannot open web browser for you. Install web browser and open:", + initialvalue=line_login_site, + ) + def cb_get_cookies(self): Thread(target=self.cb_get_cookies_thread, daemon=True).start() - - def cb_get_cookies_thread(self, *args): + + def cb_get_cookies_thread(self, *args: Any): m = GetLineAuth() line_cookies = None line_cookies = m.get_cred() if line_cookies: - if not self.gui.creds.get('line'): - self.gui.creds['line'] = {} - self.gui.creds['line']['cookies'] = line_cookies + if not self.gui.creds.get("line"): + self.gui.creds["line"] = {} + self.gui.creds["line"]["cookies"] = line_cookies self.gui.line_cookies_var.set(line_cookies) - - self.cb_msg_block_line('Got Line cookies successfully') + + self.cb_msg_block_line("Got Line cookies successfully") self.gui.save_creds() self.gui.highlight_fields() return - - self.cb_msg_block_line('Failed to get Line cookies. Have you logged in the web browser?') + + self.cb_msg_block_line( + "Failed to get Line cookies. Have you logged in the web browser?" + ) diff --git a/src/sticker_convert/gui_components/windows/signal_get_auth_window.py b/src/sticker_convert/gui_components/windows/signal_get_auth_window.py index 18088b6..2300135 100644 --- a/src/sticker_convert/gui_components/windows/signal_get_auth_window.py +++ b/src/sticker_convert/gui_components/windows/signal_get_auth_window.py @@ -1,19 +1,20 @@ #!/usr/bin/env python3 from functools import partial from threading import Thread +from typing import Any from ttkbootstrap import Button, Frame, Label, Toplevel # type: ignore -from sticker_convert.gui_components.gui_utils import GUIUtils # type: ignore -from sticker_convert.gui_components.windows.base_window import BaseWindow # type: ignore -from sticker_convert.utils.auth.get_signal_auth import GetSignalAuth # type: ignore +from sticker_convert.gui_components.gui_utils import GUIUtils +from sticker_convert.gui_components.windows.base_window import BaseWindow +from sticker_convert.utils.auth.get_signal_auth import GetSignalAuth class SignalGetAuthWindow(BaseWindow): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(SignalGetAuthWindow, self).__init__(*args, **kwargs) - self.title('Get Signal uuid and password') + self.title("Get Signal uuid and password") self.cb_msg_block_signal = partial(self.gui.cb_msg_block, parent=self) self.cb_ask_str_signal = partial(self.gui.cb_ask_str, parent=self) @@ -21,29 +22,52 @@ def __init__(self, *args, **kwargs): self.frame_info = Frame(self.scrollable_frame) self.frame_start_btn = Frame(self.scrollable_frame) - self.frame_info.grid(column=0, row=0, sticky='news', padx=3, pady=3) - self.frame_start_btn.grid(column=0, row=1, sticky='news', padx=3, pady=3) + self.frame_info.grid(column=0, row=0, sticky="news", padx=3, pady=3) + self.frame_start_btn.grid(column=0, row=1, sticky="news", padx=3, pady=3) # Info frame - self.explanation1_lbl = Label(self.frame_info, text='Please install Signal Desktop BETA VERSION', justify='left', anchor='w') - self.explanation2_lbl = Label(self.frame_info, text='After installation, you need to login to Signal Desktop', justify='left', anchor='w') - self.explanation3_lbl = Label(self.frame_info, text='uuid and password will be automatically fetched', justify='left', anchor='w') + self.explanation1_lbl = Label( + self.frame_info, + text="Please install Signal Desktop BETA VERSION", + justify="left", + anchor="w", + ) + self.explanation2_lbl = Label( + self.frame_info, + text="After installation, you need to login to Signal Desktop", + justify="left", + anchor="w", + ) + self.explanation3_lbl = Label( + self.frame_info, + text="uuid and password will be automatically fetched", + justify="left", + anchor="w", + ) - self.explanation1_lbl.grid(column=0, row=0, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation2_lbl.grid(column=0, row=1, columnspan=3, sticky='w', padx=3, pady=3) - self.explanation3_lbl.grid(column=0, row=2, columnspan=3, sticky='w', padx=3, pady=3) + self.explanation1_lbl.grid( + column=0, row=0, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation2_lbl.grid( + column=0, row=1, columnspan=3, sticky="w", padx=3, pady=3 + ) + self.explanation3_lbl.grid( + column=0, row=2, columnspan=3, sticky="w", padx=3, pady=3 + ) # Start button frame - self.login_btn = Button(self.frame_start_btn, text='Get uuid and password', command=self.cb_login) + self.login_btn = Button( + self.frame_start_btn, text="Get uuid and password", command=self.cb_login + ) self.login_btn.pack() GUIUtils.finalize_window(self) - + def cb_login(self): Thread(target=self.cb_login_thread, daemon=True).start() - - def cb_login_thread(self, *args): + + def cb_login_thread(self, *args: Any): m = GetSignalAuth(cb_msg=self.gui.cb_msg, cb_ask_str=self.cb_ask_str_signal) uuid, password = None, None @@ -51,17 +75,19 @@ def cb_login_thread(self, *args): uuid, password = m.get_cred() if uuid and password: - if not self.gui.creds.get('signal'): - self.gui.creds['signal'] = {} - self.gui.creds['signal']['uuid'] = uuid - self.gui.creds['signal']['password'] = password + if not self.gui.creds.get("signal"): + self.gui.creds["signal"] = {} + self.gui.creds["signal"]["uuid"] = uuid + self.gui.creds["signal"]["password"] = password self.gui.signal_uuid_var.set(uuid) self.gui.signal_password_var.set(password) m.close() - - self.cb_msg_block_signal(f'Got uuid and password successfully:\nuuid={uuid}\npassword={password}') + + self.cb_msg_block_signal( + f"Got uuid and password successfully:\nuuid={uuid}\npassword={password}" + ) self.gui.save_creds() self.gui.highlight_fields() return - - self.cb_msg_block_signal('Failed to get uuid and password') + + self.cb_msg_block_signal("Failed to get uuid and password") diff --git a/src/sticker_convert/job.py b/src/sticker_convert/job.py index 4123487..1811559 100755 --- a/src/sticker_convert/job.py +++ b/src/sticker_convert/job.py @@ -2,88 +2,100 @@ from __future__ import annotations import os +import platform import shutil import traceback from datetime import datetime from multiprocessing import Process, Value -from multiprocessing.managers import BaseProxy, SyncManager +from multiprocessing.managers import SyncManager from pathlib import Path -import platform +from queue import Queue from threading import Thread -from typing import Optional, Callable, Union +from typing import Callable, Generator, Optional, Union, Any from urllib.parse import urlparse -from sticker_convert.converter import StickerConvert # type: ignore -from sticker_convert.definitions import ROOT_DIR # type: ignore -from sticker_convert.downloaders.download_kakao import DownloadKakao # type: ignore -from sticker_convert.downloaders.download_line import DownloadLine # type: ignore -from sticker_convert.downloaders.download_signal import DownloadSignal # type: ignore -from sticker_convert.downloaders.download_telegram import DownloadTelegram # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - InputOption, OutputOption) -from sticker_convert.uploaders.compress_wastickers import CompressWastickers # type: ignore -from sticker_convert.uploaders.upload_base import UploadBase -from sticker_convert.uploaders.upload_signal import UploadSignal # type: ignore -from sticker_convert.uploaders.upload_telegram import UploadTelegram # type: ignore -from sticker_convert.uploaders.xcode_imessage import XcodeImessage, XcodeImessageIconset # type: ignore -from sticker_convert.utils.callback import CallbackReturn # type: ignore -from sticker_convert.utils.files.json_manager import JsonManager # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore - -work_queue_type: BaseProxy[Optional[tuple]] -results_queue_type: BaseProxy[ - Union[ - tuple[ - Optional[str], - Optional[tuple], - Optional[dict]], - str, - None - ] - ] -cb_queue_type: BaseProxy[Optional[tuple[str, tuple]]] +from sticker_convert.converter import StickerConvert +from sticker_convert.definitions import ROOT_DIR +from sticker_convert.downloaders.download_kakao import DownloadKakao +from sticker_convert.downloaders.download_line import DownloadLine +from sticker_convert.downloaders.download_signal import DownloadSignal +from sticker_convert.downloaders.download_telegram import DownloadTelegram +from sticker_convert.job_option import CompOption, CredOption, InputOption, OutputOption +from sticker_convert.uploaders.compress_wastickers import CompressWastickers +from sticker_convert.uploaders.upload_signal import UploadSignal +from sticker_convert.uploaders.upload_telegram import UploadTelegram +from sticker_convert.uploaders.xcode_imessage import XcodeImessage +from sticker_convert.utils.callback import CallbackReturn +from sticker_convert.utils.files.json_manager import JsonManager +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.codec_info import CodecInfo + class Executor: - def __init__(self, - cb_msg: Callable, - cb_msg_block: Callable, - cb_bar: Callable, - cb_ask_bool: Callable, - cb_ask_str: Callable): - + def __init__( + self, + cb_msg: Callable[..., None], + cb_msg_block: Callable[..., None], + cb_bar: Callable[..., None], + cb_ask_bool: Callable[..., bool], + cb_ask_str: Callable[..., str], + ): self.cb_msg = cb_msg self.cb_msg_block = cb_msg_block self.cb_bar = cb_bar self.cb_ask_bool = cb_ask_bool self.cb_ask_str = cb_ask_str - + self.manager = SyncManager() self.manager.start() - self.work_queue: work_queue_type = self.manager.Queue() - self.results_queue: results_queue_type = self.manager.Queue() - self.cb_queue: cb_queue_type = self.manager.Queue() + self.work_queue: Queue[ + Optional[tuple[Callable[..., Any], tuple[Any, ...]]] + ] = self.manager.Queue() + self.results_queue: Queue[Any] = self.manager.Queue() + self.cb_queue: Queue[ + Union[ + tuple[ + Optional[str], Optional[tuple[Any, ...]], Optional[dict[str, str]] + ], + str, + None, + ] + ] = self.manager.Queue() self.cb_return = CallbackReturn() self.processes: list[Process] = [] - self.is_cancel_job = Value('i', 0) + self.is_cancel_job = Value("i", 0) - Thread(target=self.cb_thread, args=(self.cb_queue, self.cb_return,)).start() + self.cb_thread_instance = Thread( + target=self.cb_thread, + args=( + self.cb_queue, + self.cb_return, + ), + ) + self.cb_thread_instance.start() def cb_thread( - self, - cb_queue: work_queue_type, - cb_return: CallbackReturn - ): - for i in iter(cb_queue.get, None): # type: ignore[misc] + self, + cb_queue: Queue[ + Union[ + tuple[ + Optional[str], Optional[tuple[Any, ...]], Optional[dict[str, str]] + ], + str, + ] + ], + cb_return: CallbackReturn, + ): + for i in iter(cb_queue.get, None): # type: ignore[misc] if isinstance(i, tuple): action = i[0] if len(i) >= 2: - args = i[1] if i[1] else tuple() + args: Optional[tuple[str, ...]] = i[1] if i[1] else tuple() else: args = tuple() if len(i) >= 3: - kwargs = i[2] if i[2] else dict() + kwargs: Optional[dict[str, str]] = i[2] if i[2] else dict() else: kwargs = dict() else: @@ -107,22 +119,28 @@ def cb_thread( @staticmethod def worker( - work_queue: work_queue_type, - results_queue: results_queue_type, - cb_queue: cb_queue_type, - cb_return: CallbackReturn - ): - + work_queue: Queue[Optional[tuple[Callable[..., Any], tuple[Any, ...]]]], + results_queue: Queue[Any], + cb_queue: Queue[ + Union[ + tuple[ + Optional[str], Optional[tuple[Any, ...]], Optional[dict[str, str]] + ], + str, + ] + ], + cb_return: CallbackReturn, + ): for work_func, work_args in iter(work_queue.get, None): try: - results = work_func(*work_args, cb_queue, cb_return) # type: ignore + results = work_func(*work_args, cb_queue, cb_return) # type: ignore results_queue.put(results) except Exception: - e = '##### EXCEPTION #####\n' - e += 'Function: ' + repr(work_func) + '\n' - e += 'Arguments: ' + repr(work_args) + '\n' + e = "##### EXCEPTION #####\n" + e += "Function: " + repr(work_func) + "\n" + e += "Arguments: " + repr(work_args) + "\n" e += traceback.format_exc() - e += '#####################' + e += "#####################" cb_queue.put(e) work_queue.put(None) @@ -134,14 +152,19 @@ def start_workers(self, processes: int = 1): for _ in range(processes): process = Process( target=Executor.worker, - args=(self.work_queue, self.results_queue, self.cb_queue, self.cb_return), - daemon=True + args=( + self.work_queue, + self.results_queue, + self.cb_queue, + self.cb_return, + ), + daemon=True, ) process.start() self.processes.append(process) - def add_work(self, work_func: Callable, work_args: tuple): + def add_work(self, work_func: Callable[..., Any], work_args: tuple[Any, ...]): self.work_queue.put((work_func, work_args)) def join_workers(self): @@ -151,13 +174,13 @@ def join_workers(self): process.join() except KeyboardInterrupt: pass - + self.results_queue.put(None) self.process = [] - def kill_workers(self, *args, **kwargs): - self.is_cancel_job.value = 1 + def kill_workers(self, *args: Any, **kwargs: Any): + self.is_cancel_job.value = 1 # type: ignore while not self.work_queue.empty(): self.work_queue.get() @@ -167,58 +190,67 @@ def kill_workers(self, *args, **kwargs): else: process.close() process.join() - + self.cleanup() - + def cleanup(self): self.cb_queue.put(None) + self.cb_thread_instance.join() - def get_result(self) -> tuple: + def get_result(self) -> Generator[Any, None, None]: for result in iter(self.results_queue.get, None): yield result - - def cb(self, action: Optional[str], args: Optional[tuple] = None, kwargs: Optional[dict] = None): + + def cb( + self, + action: Optional[str], + args: Optional[tuple[str, ...]] = None, + kwargs: Optional[dict[str, Any]] = None, + ): self.cb_queue.put((action, args, kwargs)) class Job: - def __init__(self, - opt_input: InputOption, opt_comp: CompOption, - opt_output: OutputOption, opt_cred: CredOption, - cb_msg: Callable, - cb_msg_block: Callable, - cb_bar: Callable, - cb_ask_bool: Callable, - cb_ask_str: Callable): - - self.opt_input: InputOption = opt_input - self.opt_comp: CompOption = opt_comp - self.opt_output: OutputOption = opt_output - self.opt_cred: CredOption = opt_cred - self.cb_msg: Callable = cb_msg - self.cb_msg_block: Callable = cb_msg_block - self.cb_bar: Callable = cb_bar - self.cb_ask_bool: Callable = cb_ask_bool - self.cb_ask_str: Callable = cb_ask_str + def __init__( + self, + opt_input: InputOption, + opt_comp: CompOption, + opt_output: OutputOption, + opt_cred: CredOption, + cb_msg: Callable[..., None], + cb_msg_block: Callable[..., None], + cb_bar: Callable[..., None], + cb_ask_bool: Callable[..., bool], + cb_ask_str: Callable[..., str], + ): + self.opt_input = opt_input + self.opt_comp = opt_comp + self.opt_output = opt_output + self.opt_cred = opt_cred + self.cb_msg = cb_msg + self.cb_msg_block = cb_msg_block + self.cb_bar = cb_bar + self.cb_ask_bool = cb_ask_bool + self.cb_ask_str = cb_ask_str self.compress_fails: list[str] = [] self.out_urls: list[str] = [] - self.executor: Executor = Executor( + self.executor = Executor( self.cb_msg, self.cb_msg_block, self.cb_bar, self.cb_ask_bool, - self.cb_ask_str + self.cb_ask_str, ) def start(self) -> int: - if Path(self.opt_input.dir).is_dir() == False: + if Path(self.opt_input.dir).is_dir() is False: os.makedirs(self.opt_input.dir) - if Path(self.opt_output.dir).is_dir() == False: + if Path(self.opt_output.dir).is_dir() is False: os.makedirs(self.opt_output.dir) - + self.executor.cb("msg", kwargs={"cls": True}) tasks = ( @@ -227,7 +259,7 @@ def start(self) -> int: self.download, self.compress, self.export, - self.report + self.report, ) code = 0 @@ -235,197 +267,205 @@ def start(self) -> int: self.executor.cb("bar", kwargs={"set_progress_mode": "indeterminate"}) success = task() - if self.executor.is_cancel_job.value == 1: + if self.executor.is_cancel_job.value == 1: # type: ignore code = 2 - self.executor.cb('Job cancelled.') + self.executor.cb("Job cancelled.") break elif not success: code = 1 - self.executor.cb('An error occured during this run.') + self.executor.cb("An error occured during this run.") break - self.executor.cb("bar", kwargs={"set_progress_mode": 'clear'}) + self.executor.cb("bar", kwargs={"set_progress_mode": "clear"}) self.executor.cleanup() return code - def cancel(self, *args, **kwargs): + def cancel(self, *args: Any, **kwargs: Any): self.executor.kill_workers() def verify_input(self) -> bool: - info_msg = '' - error_msg = '' + info_msg = "" + error_msg = "" - save_to_local_tip = '' - save_to_local_tip += ' If you want to upload the results by yourself,\n' + save_to_local_tip = "" + save_to_local_tip += " If you want to upload the results by yourself,\n" save_to_local_tip += ' select "Save to local directory only" for output\n' if Path(self.opt_input.dir).resolve() == Path(self.opt_output.dir).resolve(): - error_msg += '\n' - error_msg += '[X] Input and output directories cannot be the same\n' - - if self.opt_input.option == 'auto': - error_msg += '\n' - error_msg += '[X] Unrecognized URL input source\n' + error_msg += "\n" + error_msg += "[X] Input and output directories cannot be the same\n" - if (self.opt_input.option != 'local' and - not self.opt_input.url): + if self.opt_input.option == "auto": + error_msg += "\n" + error_msg += "[X] Unrecognized URL input source\n" - error_msg += '\n' - error_msg += '[X] URL address cannot be empty.\n' - error_msg += ' If you only want to use local files,\n' + if self.opt_input.option != "local" and not self.opt_input.url: + error_msg += "\n" + error_msg += "[X] URL address cannot be empty.\n" + error_msg += " If you only want to use local files,\n" error_msg += ' choose "Save to local directory only"\n' error_msg += ' in "Input source"\n' - - if ((self.opt_input.option == 'telegram' or - self.opt_output.option == 'telegram') and - not self.opt_cred.telegram_token): - - error_msg += '[X] Downloading from and uploading to telegram requires bot token.\n' + if ( + self.opt_input.option == "telegram" or self.opt_output.option == "telegram" + ) and not self.opt_cred.telegram_token: + error_msg += ( + "[X] Downloading from and uploading to telegram requires bot token.\n" + ) error_msg += save_to_local_tip - if (self.opt_output.option == 'telegram' and - not self.opt_cred.telegram_userid): - - error_msg += '[X] Uploading to telegram requires user_id \n' - error_msg += ' (From real account, not bot account).\n' + if self.opt_output.option == "telegram" and not self.opt_cred.telegram_userid: + error_msg += "[X] Uploading to telegram requires user_id \n" + error_msg += " (From real account, not bot account).\n" error_msg += save_to_local_tip - - if (self.opt_output.option == 'signal' and - not (self.opt_cred.signal_uuid and self.opt_cred.signal_password)): - - error_msg += '[X] Uploading to signal requires uuid and password.\n' + if self.opt_output.option == "signal" and not ( + self.opt_cred.signal_uuid and self.opt_cred.signal_password + ): + error_msg += "[X] Uploading to signal requires uuid and password.\n" error_msg += save_to_local_tip - - output_presets = JsonManager.load_json(ROOT_DIR / 'resources/output.json') + + output_presets = JsonManager.load_json(ROOT_DIR / "resources/output.json") input_option = self.opt_input.option output_option = self.opt_output.option - - for metadata in ('title', 'author'): - if MetadataHandler.check_metadata_required(output_option, metadata) and not getattr(self.opt_output, metadata): - if not MetadataHandler.check_metadata_provided(self.opt_input.dir, input_option, metadata): + + for metadata in ("title", "author"): + if MetadataHandler.check_metadata_required( + output_option, metadata + ) and not getattr(self.opt_output, metadata): + if not MetadataHandler.check_metadata_provided( + self.opt_input.dir, input_option, metadata + ): error_msg += f'[X] {output_presets[output_option]["full_name"]} requires {metadata}\n' - if self.opt_input.option == 'local': - error_msg += f' {metadata} was not supplied and {metadata}.txt is absent\n' + if self.opt_input.option == "local": + error_msg += f" {metadata} was not supplied and {metadata}.txt is absent\n" else: - error_msg += f' {metadata} was not supplied and input source will not provide {metadata}\n' - error_msg += f' Supply the {metadata} by filling in the option, or\n' - error_msg += f' Create {metadata}.txt with the {metadata} name\n' + error_msg += f" {metadata} was not supplied and input source will not provide {metadata}\n" + error_msg += ( + f" Supply the {metadata} by filling in the option, or\n" + ) + error_msg += f" Create {metadata}.txt with the {metadata} name\n" else: info_msg += f'[!] {output_presets[output_option]["full_name"]} requires {metadata}\n' - if self.opt_input.option == 'local': - info_msg += f' {metadata} was not supplied but {metadata}.txt is present\n' - info_msg += f' Using {metadata} name in {metadata}.txt\n' + if self.opt_input.option == "local": + info_msg += f" {metadata} was not supplied but {metadata}.txt is present\n" + info_msg += f" Using {metadata} name in {metadata}.txt\n" else: - info_msg += f' {metadata} was not supplied but input source will provide {metadata}\n' - info_msg += f' Using {metadata} provided by input source\n' - - if info_msg != '': + info_msg += f" {metadata} was not supplied but input source will provide {metadata}\n" + info_msg += f" Using {metadata} provided by input source\n" + + if info_msg != "": self.executor.cb(info_msg) - if error_msg != '': + if error_msg != "": self.executor.cb(error_msg) return False - + # Check if preset not equal to export option # Only warn if the compression option is available in export preset # Only warn if export option is not local or custom # Do not warn if no_compress is true - if (not self.opt_comp.no_compress and - self.opt_output.option != 'local' and - self.opt_comp.preset != 'custom' and - self.opt_output.option not in self.opt_comp.preset): - - msg = 'Compression preset does not match export option\n' - msg += 'You may continue, but the files will need to be compressed again before export\n' - msg += 'You are recommended to choose the matching option for compression and output. Continue?' + if ( + not self.opt_comp.no_compress + and self.opt_output.option != "local" + and self.opt_comp.preset != "custom" + and self.opt_output.option not in self.opt_comp.preset + ): + msg = "Compression preset does not match export option\n" + msg += "You may continue, but the files will need to be compressed again before export\n" + msg += "You are recommended to choose the matching option for compression and output. Continue?" self.executor.cb("ask_bool", (msg,)) response = self.executor.cb_return.get_response() - if response == False: + if response is False: return False - + for param, value in ( - ('fps_power', self.opt_comp.fps_power), - ('res_power', self.opt_comp.res_power), - ('quality_power', self.opt_comp.quality_power), - ('color_power', self.opt_comp.color_power) + ("fps_power", self.opt_comp.fps_power), + ("res_power", self.opt_comp.res_power), + ("quality_power", self.opt_comp.quality_power), + ("color_power", self.opt_comp.color_power), ): if value < -1: - error_msg += '\n' - error_msg += f'[X] {param} should be between -1 and positive infinity. {value} was given.' - - if self.opt_comp.scale_filter not in ('nearest', 'bilinear', 'bicubic', 'lanczos'): - error_msg += '\n' - error_msg += f'[X] scale_filter {self.opt_comp.scale_filter} is not valid option' - error_msg += ' Valid options: nearest, bilinear, bicubic, lanczos' - - if self.opt_comp.quantize_method not in ('imagequant', 'fastoctree', 'none'): - error_msg += '\n' - error_msg += f'[X] quantize_method {self.opt_comp.quantize_method} is not valid option' - error_msg += ' Valid options: imagequant, fastoctree, none' - - # Warn about unable to download animated Kakao stickers with such link - if (self.opt_output.option == 'kakao' and - urlparse(self.opt_input.url).netloc == 'e.kakao.com' and - not self.opt_cred.kakao_auth_token): + error_msg += "\n" + error_msg += f"[X] {param} should be between -1 and positive infinity. {value} was given." + + if self.opt_comp.scale_filter not in ( + "nearest", + "bilinear", + "bicubic", + "lanczos", + ): + error_msg += "\n" + error_msg += ( + f"[X] scale_filter {self.opt_comp.scale_filter} is not valid option" + ) + error_msg += " Valid options: nearest, bilinear, bicubic, lanczos" + + if self.opt_comp.quantize_method not in ("imagequant", "fastoctree", "none"): + error_msg += "\n" + error_msg += f"[X] quantize_method {self.opt_comp.quantize_method} is not valid option" + error_msg += " Valid options: imagequant, fastoctree, none" - msg = 'To download ANIMATED stickers from e.kakao.com,\n' - msg += 'you need to generate auth_token.\n' - msg += 'Alternatively, you can generate share link (emoticon.kakao.com/items/xxxxx)\n' - msg += 'from Kakao app on phone.\n' - msg += 'You are adviced to read documentations.\n' - msg += 'If you continue, you will only download static stickers. Continue?' + # Warn about unable to download animated Kakao stickers with such link + if ( + self.opt_output.option == "kakao" + and urlparse(self.opt_input.url).netloc == "e.kakao.com" + and not self.opt_cred.kakao_auth_token + ): + msg = "To download ANIMATED stickers from e.kakao.com,\n" + msg += "you need to generate auth_token.\n" + msg += "Alternatively, you can generate share link (emoticon.kakao.com/items/xxxxx)\n" + msg += "from Kakao app on phone.\n" + msg += "You are adviced to read documentations.\n" + msg += "If you continue, you will only download static stickers. Continue?" self.executor.cb("ask_bool", (msg,)) response = self.executor.cb_return.get_response() - if response == False: + if response is False: return False - + # Warn about in/output directories that might contain other files # Directory is safe if the name is stickers_input/stickers_output, or # all contents are related to sticker-convert for path_type, path, default_name in ( ("Input", self.opt_input.dir, "stickers_input"), - ("Output", self.opt_output.dir, "stickers_output") + ("Output", self.opt_output.dir, "stickers_output"), + ): + if path_type == "Input" and ( + path.name == "stickers_input" + or self.opt_input.option == "local" + or not any(path.iterdir()) ): - - if (path_type == "Input" and - (path.name == "stickers_input" or - self.opt_input.option == "local" or - not any(path.iterdir()))): - continue - elif (path_type == "Output" and - (path.name == "stickers_output" or - self.opt_comp.no_compress or - not any(path.iterdir()))): - + elif path_type == "Output" and ( + path.name == "stickers_output" + or self.opt_comp.no_compress + or not any(path.iterdir()) + ): continue - + related_files = MetadataHandler.get_files_related_to_sticker_convert(path) - if any([ - i for i in path.iterdir() - if i not in related_files]): - - msg = 'WARNING: {} directory is set to {}.\n' + if any([i for i in path.iterdir() if i not in related_files]): + msg = "WARNING: {} directory is set to {}.\n" msg += 'It does not have default name of "{}",\n' - msg += 'and It seems like it contains PERSONAL DATA.\n' - msg += 'During execution, contents of this directory\n' + msg += "and It seems like it contains PERSONAL DATA.\n" + msg += "During execution, contents of this directory\n" msg += 'maybe MOVED to "archive_*".\n' - msg += 'THIS MAY CAUSE DAMAGE TO YOUR DATA. Continue?' + msg += "THIS MAY CAUSE DAMAGE TO YOUR DATA. Continue?" - self.executor.cb("ask_bool", (msg.format(path_type, path, default_name),)) + self.executor.cb( + "ask_bool", (msg.format(path_type, path, default_name),) + ) response = self.executor.cb_return.get_response() - if response == False: + if response is False: return False - + break return True @@ -435,28 +475,42 @@ def cleanup(self) -> bool: # If input is not 'From local directory', then we should move files in input/output directory as new files will be downloaded # Output directory should be cleanup unless no_compress is true (meaning files in output directory might be edited by user) - timestamp = datetime.now().strftime('%Y-%d-%m_%H-%M-%S') - dir_name = 'archive_' + timestamp + timestamp = datetime.now().strftime("%Y-%d-%m_%H-%M-%S") + dir_name = "archive_" + timestamp - in_dir_files = MetadataHandler.get_files_related_to_sticker_convert(self.opt_input.dir, include_archive=False) - out_dir_files = MetadataHandler.get_files_related_to_sticker_convert(self.opt_output.dir, include_archive=False) + in_dir_files = MetadataHandler.get_files_related_to_sticker_convert( + self.opt_input.dir, include_archive=False + ) + out_dir_files = MetadataHandler.get_files_related_to_sticker_convert( + self.opt_output.dir, include_archive=False + ) - if self.opt_input.option == 'local': - self.executor.cb('Skip moving old files in input directory as input source is local') + if self.opt_input.option == "local": + self.executor.cb( + "Skip moving old files in input directory as input source is local" + ) elif len(in_dir_files) == 0: - self.executor.cb('Skip moving old files in input directory as input source is empty') + self.executor.cb( + "Skip moving old files in input directory as input source is empty" + ) else: archive_dir = Path(self.opt_input.dir, dir_name) - self.executor.cb(f"Moving old files in input directory to {archive_dir} as input source is not local") + self.executor.cb( + f"Moving old files in input directory to {archive_dir} as input source is not local" + ) archive_dir.mkdir(exist_ok=True) for old_path in in_dir_files: new_path = Path(archive_dir, old_path.name) old_path.rename(new_path) if self.opt_comp.no_compress: - self.executor.cb('Skip moving old files in output directory as no_compress is True') + self.executor.cb( + "Skip moving old files in output directory as no_compress is True" + ) elif len(out_dir_files) == 0: - self.executor.cb('Skip moving old files in output directory as output source is empty') + self.executor.cb( + "Skip moving old files in output directory as output source is empty" + ) else: archive_dir = Path(self.opt_output.dir, dir_name) self.executor.cb(f"Moving old files in output directory to {archive_dir}") @@ -464,137 +518,146 @@ def cleanup(self) -> bool: for old_path in out_dir_files: new_path = Path(archive_dir, old_path.name) old_path.rename(new_path) - + return True def download(self) -> bool: - downloaders = [] + downloaders: list[Callable[..., bool]] = [] - if self.opt_input.option == 'signal': + if self.opt_input.option == "signal": downloaders.append(DownloadSignal.start) - if self.opt_input.option == 'line': + if self.opt_input.option == "line": downloaders.append(DownloadLine.start) - - if self.opt_input.option == 'telegram': + + if self.opt_input.option == "telegram": downloaders.append(DownloadTelegram.start) - if self.opt_input.option == 'kakao': + if self.opt_input.option == "kakao": downloaders.append(DownloadKakao.start) - + if len(downloaders) > 0: - self.executor.cb('Downloading...') + self.executor.cb("Downloading...") else: - self.executor.cb('Nothing to download') + self.executor.cb("Nothing to download") return True - + self.executor.start_workers(processes=1) for downloader in downloaders: self.executor.add_work( work_func=downloader, - work_args=( - self.opt_input.url, - self.opt_input.dir, - self.opt_cred - ) + work_args=(self.opt_input.url, self.opt_input.dir, self.opt_cred), ) - + self.executor.join_workers() # Return False if any of the job returns failure for result in self.executor.get_result(): - if result == False: + if result is False: return False - self.executor.cleanup() - return True def compress(self) -> bool: - if self.opt_comp.no_compress == True: - self.executor.cb('no_compress is set to True, skip compression') - in_dir_files = [i for i in sorted(self.opt_input.dir.iterdir()) if Path(self.opt_input.dir, i).is_file()] - out_dir_files = [i for i in sorted(self.opt_output.dir.iterdir()) if Path(self.opt_output.dir, i).is_file()] + if self.opt_comp.no_compress is True: + self.executor.cb("no_compress is set to True, skip compression") + in_dir_files = [ + i + for i in sorted(self.opt_input.dir.iterdir()) + if Path(self.opt_input.dir, i.name).is_file() + ] + out_dir_files = [ + i + for i in sorted(self.opt_output.dir.iterdir()) + if Path(self.opt_output.dir, i.name).is_file() + ] if len(in_dir_files) == 0: - self.executor.cb('Input directory is empty, nothing to copy to output directory') + self.executor.cb( + "Input directory is empty, nothing to copy to output directory" + ) elif len(out_dir_files) != 0: - self.executor.cb('Output directory is not empty, not copying files from input directory') + self.executor.cb( + "Output directory is not empty, not copying files from input directory" + ) else: - self.executor.cb('Output directory is empty, copying files from input directory') + self.executor.cb( + "Output directory is empty, copying files from input directory" + ) for i in in_dir_files: - src_f = Path(self.opt_input.dir, i) - dst_f = Path(self.opt_output.dir, i) + src_f = Path(self.opt_input.dir, i.name) + dst_f = Path(self.opt_output.dir, i.name) shutil.copy(src_f, dst_f) return True - msg = 'Compressing...' + msg = "Compressing..." input_dir = Path(self.opt_input.dir) output_dir = Path(self.opt_output.dir) - - in_fs = [] + + in_fs: list[Path] = [] # .txt: emoji.txt, title.txt # .m4a: line sticker sound effects for i in sorted(input_dir.iterdir()): in_f = input_dir / i - + if not in_f.is_file(): continue - elif (CodecInfo.get_file_ext(i) in ('.txt', '.m4a') or - Path(i).stem == 'cover'): - - shutil.copy(in_f, output_dir / i) + elif ( + CodecInfo.get_file_ext(i) in (".txt", ".m4a") or Path(i).stem == "cover" + ): + shutil.copy(in_f, output_dir / i.name) else: in_fs.append(i) in_fs_count = len(in_fs) self.executor.cb(msg) - self.executor.cb("bar", kwargs={'set_progress_mode': 'determinate', 'steps': in_fs_count}) + self.executor.cb( + "bar", kwargs={"set_progress_mode": "determinate", "steps": in_fs_count} + ) self.executor.start_workers(processes=min(self.opt_comp.processes, in_fs_count)) for i in in_fs: - in_f = input_dir / i + in_f = input_dir / i.name out_f = output_dir / Path(i).stem self.executor.add_work( - work_func=StickerConvert.convert, - work_args=(in_f, out_f, self.opt_comp) + work_func=StickerConvert.convert, work_args=(in_f, out_f, self.opt_comp) ) self.executor.join_workers() # Return False if any of the job returns failure for result in self.executor.get_result(): - if result[0] == False: + if result[0] is False: return False - + return True def export(self) -> bool: - if self.opt_output.option == 'local': - self.executor.cb('Saving to local directory only, nothing to export') + if self.opt_output.option == "local": + self.executor.cb("Saving to local directory only, nothing to export") return True - - self.executor.cb('Exporting...') - exporters: list[UploadBase] = [] + self.executor.cb("Exporting...") - if self.opt_output.option == 'whatsapp': + exporters: list[Callable[..., list[str]]] = [] + + if self.opt_output.option == "whatsapp": exporters.append(CompressWastickers.start) - if self.opt_output.option == 'signal': + if self.opt_output.option == "signal": exporters.append(UploadSignal.start) - if self.opt_output.option == 'telegram': + if self.opt_output.option == "telegram": exporters.append(UploadTelegram.start) - - if self.opt_output.option == 'telegram_emoji': + + if self.opt_output.option == "telegram_emoji": exporters.append(UploadTelegram.start) - if self.opt_output.option == 'imessage': + if self.opt_output.option == "imessage": exporters.append(XcodeImessage.start) self.executor.start_workers(processes=1) @@ -602,11 +665,7 @@ def export(self) -> bool: for exporter in exporters: self.executor.add_work( work_func=exporter, - work_args=( - self.opt_output, - self.opt_comp, - self.opt_cred - ) + work_args=(self.opt_output, self.opt_comp, self.opt_cred), ) self.executor.join_workers() @@ -615,33 +674,33 @@ def export(self) -> bool: self.out_urls.extend(result) if self.out_urls: - with open(Path(self.opt_output.dir, 'export-result.txt'), 'w+') as f: - f.write('\n'.join(self.out_urls)) + with open(Path(self.opt_output.dir, "export-result.txt"), "w+") as f: + f.write("\n".join(self.out_urls)) else: - self.executor.cb('An error occured while exporting stickers') + self.executor.cb("An error occured while exporting stickers") return False - + return True - + def report(self) -> bool: - msg = '##########\n' - msg += 'Summary:\n' - msg += '##########\n' - msg += '\n' + msg = "##########\n" + msg += "Summary:\n" + msg += "##########\n" + msg += "\n" if self.compress_fails != []: msg += f'Warning: Could not compress the following {len(self.compress_fails)} file{"s" if len(self.compress_fails) > 1 else ""}:\n' msg += "\n".join(self.compress_fails) - msg += '\n' - msg += '\nConsider adjusting compression parameters' - msg += '\n' + msg += "\n" + msg += "\nConsider adjusting compression parameters" + msg += "\n" if self.out_urls != []: - msg += 'Export results:\n' - msg += '\n'.join(self.out_urls) + msg += "Export results:\n" + msg += "\n".join(self.out_urls) else: - msg += 'Export result: None' + msg += "Export result: None" self.executor.cb(msg) - return True \ No newline at end of file + return True diff --git a/src/sticker_convert/job_option.py b/src/sticker_convert/job_option.py index 9b0ef94..922f858 100755 --- a/src/sticker_convert/job_option.py +++ b/src/sticker_convert/job_option.py @@ -1,318 +1,249 @@ #!/usr/bin/env python3 from __future__ import annotations +from typing import Any, Optional, Union +import json +import math +from dataclasses import dataclass, field +from multiprocessing import cpu_count from pathlib import Path -from typing import Optional, Union -def to_int(i) -> Optional[int]: - return int(i) if i != None else None +def to_int(i: Union[float, str, None]) -> Optional[int]: + return int(i) if i is not None else None + +@dataclass class BaseOption: def merge(self, config: "BaseOption"): for k, v in vars(config).items(): - if v != None: + if v is not None: setattr(self, k, v) + def __repr__(self) -> str: + return json.dumps(self.to_dict(), indent=2) + + def to_dict(self) -> dict[str, str]: + return dict() + + +@dataclass class InputOption(BaseOption): - def __init__(self, input_config_dict: dict): - self.option: Optional[str] = input_config_dict.get('option') - self.url: Optional[str] = input_config_dict.get('url') - self.dir: Path = Path(input_config_dict.get('dir')) - - def to_dict(self) -> dict: - return { - 'option': self.option, - 'url': self.url, - 'dir': self.dir.as_posix() - } + option: str = "local" + url: str = "" + dir: Path = Path() + + def to_dict(self) -> dict[Any, Any]: + return {"option": self.option, "url": self.url, "dir": self.dir.as_posix()} + +@dataclass class CompOption(BaseOption): - def __init__(self, comp_config_dict: dict): - self.preset: Optional[str] = comp_config_dict.get('preset') - - size_max: Union[dict, int, None] = comp_config_dict.get('size_max') - if isinstance(size_max, dict): - self.size_max_img: Optional[int] = to_int(size_max.get('img')) - self.size_max_vid: Optional[int] = to_int(size_max.get('vid')) - else: - self.size_max_img: Optional[int] = to_int(size_max) - self.size_max_vid: Optional[int] = to_int(size_max) - - fmt: Union[dict, list, str, None] = comp_config_dict.get('format') - if isinstance(fmt, dict): - self.format_img: Optional[str] = fmt.get('img') - self.format_vid: Optional[str] = fmt.get('vid') - else: - self.format_img: Optional[str] = fmt - self.format_vid: Optional[str] = fmt - - fps: Union[dict, int, None] = comp_config_dict.get('fps') - if isinstance(fps, dict): - self.fps_min: Optional[int] = to_int(fps.get('min')) - self.fps_max: Optional[int] = to_int(fps.get('max')) - self.fps_power: float = fps.get('power') if fps.get('power') else -0.5 - else: - self.fps_min: Optional[int] = to_int(fps) - self.fps_max: Optional[int] = to_int(fps) - self.fps_power: float = -0.5 - - self.res_w_min: Optional[int] = None - self.res_w_max: Optional[int] = None - self.res_h_min: Optional[int] = None - self.res_h_max: Optional[int] = None - if isinstance(res := comp_config_dict.get('res'), dict): - if res_w := res.get('w'): - if isinstance(res_w, dict): - self.res_w_min = to_int(res_w.get('min')) - self.res_w_max = to_int(res_w.get('max')) - else: - self.res_w_min = res_w - self.res_w_max = res_w - if res_h := res.get('h'): - if isinstance(res_h, dict): - self.res_h_min = to_int(res_h.get('min', res_h)) - self.res_h_max = to_int(res_h.get('max', res_h)) - else: - self.res_h_min = res_h - self.res_h_max = res_h - if res_min := res.get('min'): - if isinstance(res_min, dict): - self.res_w_min = to_int(res_min.get('w', res_min)) - self.res_h_min = to_int(res_min.get('h', res_min)) - else: - self.res_w_min = res_min - self.res_h_min = res_min - if res_max := res.get('max'): - if isinstance(res_max, dict): - self.res_w_max = to_int(res_max.get('w', res_max)) - self.res_h_max = to_int(res_max.get('h', res_max)) - else: - self.res_w_max = res_max - self.res_h_max = res_max - self.res_power: float = res.get('power') if res.get('power') else 3.0 - else: - self.res_w_min = to_int(res) - self.res_w_max = to_int(res) - self.res_h_min = to_int(res) - self.res_h_max = to_int(res) - self.res_power: float = 3.0 - - quality: Union[dict, int, None] = comp_config_dict.get('quality') - if isinstance(quality, dict): - self.quality_min: Optional[int] = to_int(quality.get('min')) - self.quality_max: Optional[int] = to_int(quality.get('max')) - self.quality_power: float = quality.get('power') if quality.get('power') else 5.0 - else: - self.quality_min: Optional[int] = to_int(quality) - self.quality_max: Optional[int] = to_int(quality) - self.quality_power: float = 5.0 - - color: Union[dict, int, None] = comp_config_dict.get('color') - if isinstance(color, dict): - self.color_min: Optional[int] = to_int(color.get('min')) - self.color_max: Optional[int] = to_int(color.get('max')) - self.color_power: float = color.get('power') if color.get('power') else 3.0 - else: - self.color_min: Optional[int] = to_int(color) - self.color_max: Optional[int] = to_int(color) - self.color_power: float = 3.0 - - duration: Union[dict, int, None] = comp_config_dict.get('duration') - if isinstance(duration, dict): - self.duration_min: Optional[int] = to_int(duration.get('min')) - self.duration_max: Optional[int] = to_int(duration.get('max')) - else: - self.duration_min: Optional[int] = to_int(duration) - self.duration_max: Optional[int] = to_int(duration) - - self.steps: Optional[int] = to_int(comp_config_dict.get('steps')) - self.fake_vid: Optional[bool] = comp_config_dict.get('fake_vid') - self.quantize_method: Optional[str] = comp_config_dict.get('quantize_method', 'imagequant') - self.scale_filter: Optional[str] = comp_config_dict.get('scale_filter', 'lanczos') - self.cache_dir: Optional[str] = comp_config_dict.get('cache_dir') - self.default_emoji: Optional[str] = comp_config_dict.get('default_emoji') - self.no_compress: Optional[bool] = comp_config_dict.get('no_compress') - self.processes: Optional[int] = to_int(comp_config_dict.get('processes')) - - # Only used for format verification - self.animated: Optional[bool] = comp_config_dict.get('animated') - self.square: Optional[bool] = comp_config_dict.get('square') - - def to_dict(self) -> dict: + preset: str = "auto" + size_max_img: Optional[int] = None + size_max_vid: Optional[int] = None + + format_img: list[str] = field(default_factory=lambda: []) + format_vid: list[str] = field(default_factory=lambda: []) + + fps_min: Optional[int] = None + fps_max: Optional[int] = None + fps_power: float = -0.5 + + res_w_min: Optional[int] = None + res_w_max: Optional[int] = None + res_h_min: Optional[int] = None + res_h_max: Optional[int] = None + res_power: float = 3.0 + + quality_min: Optional[int] = None + quality_max: Optional[int] = None + quality_power: float = 5.0 + + color_min: Optional[int] = None + color_max: Optional[int] = None + color_power: float = 3.0 + + duration_min: Optional[int] = None + duration_max: Optional[int] = None + + steps: int = 1 + fake_vid: Optional[bool] = None + quantize_method: Optional[str] = None + scale_filter: Optional[str] = None + cache_dir: Optional[str] = None + default_emoji: str = "😀" + no_compress: Optional[bool] = None + processes: int = math.ceil(cpu_count() / 2) + animated: Optional[bool] = None + square: Optional[bool] = None + + def to_dict(self) -> dict[Any, Any]: return { - 'preset': self.preset, - 'size_max': { - 'img': self.size_max_img, - 'vid': self.size_max_vid - }, - 'format': { - 'img': self.format_img, - 'vid': self.format_vid - }, - 'fps': { - 'min': self.fps_min, - 'max': self.fps_max, - 'power': self.fps_power + "preset": self.preset, + "size_max": {"img": self.size_max_img, "vid": self.size_max_vid}, + "format": {"img": self.format_img, "vid": self.format_vid}, + "fps": {"min": self.fps_min, "max": self.fps_max, "power": self.fps_power}, + "res": { + "w": {"min": self.res_w_min, "max": self.res_w_max}, + "h": {"min": self.res_h_min, "max": self.res_h_max}, + "power": self.res_power, }, - 'res': { - 'w': { - 'min': self.res_w_min, - 'max': self.res_w_max - }, - 'h': { - 'min': self.res_h_min, - 'max': self.res_h_max - }, - 'power': self.res_power + "quality": { + "min": self.quality_min, + "max": self.quality_max, + "power": self.quality_power, }, - 'quality': { - 'min': self.quality_min, - 'max': self.quality_max, - 'power': self.quality_power + "color": { + "min": self.color_min, + "max": self.color_max, + "power": self.color_power, }, - 'color': { - 'min': self.color_min, - 'max': self.color_max, - 'power': self.color_power - }, - 'duration': { - 'min': self.duration_min, - 'max': self.duration_max - }, - 'steps': self.steps, - 'fake_vid': self.fake_vid, - 'scale_filter': self.scale_filter, - 'cache_dir': self.cache_dir, - 'default_emoji': self.default_emoji, - 'no_compress': self.no_compress, - 'processes': self.processes, - 'animated': self.animated, - 'square': self.square + "duration": {"min": self.duration_min, "max": self.duration_max}, + "steps": self.steps, + "fake_vid": self.fake_vid, + "scale_filter": self.scale_filter, + "cache_dir": self.cache_dir, + "default_emoji": self.default_emoji, + "no_compress": self.no_compress, + "processes": self.processes, + "animated": self.animated, + "square": self.square, } - + @property def size_max(self) -> list[Optional[int]]: return [self.size_max_img, self.size_max_vid] - + @size_max.setter def size_max(self, value: Optional[int]): self.size_max_img, self.size_max_vid = to_int(value), to_int(value) - + @property - def format(self) -> list[Union[list[str], str, None]]: + def format(self) -> list[list[str]]: return [self.format_img, self.format_vid] - + @format.setter - def format(self, value: Union[list[str], str, None]): + def format(self, value: list[str]): self.format_img, self.format_vid = value, value - + @property def fps(self) -> list[Optional[int]]: return [self.fps_min, self.fps_max] - + @fps.setter def fps(self, value: Optional[int]): self.fps_min, self.fps_max = to_int(value), to_int(value) - + @property - def res(self) -> list[Optional[list[Optional[int]]]]: + def res(self) -> list[list[Optional[int]]]: return [self.res_w, self.res_h] - + @res.setter def res(self, value: Optional[int]): self.res_w_min = to_int(value) self.res_w_max = to_int(value) self.res_h_min = to_int(value) self.res_h_max = to_int(value) - + + @property + def res_max(self) -> list[Optional[int]]: + return [self.res_w_max, self.res_h_max] + + @res_max.setter + def res_max(self, value: Optional[int]): + self.res_w_max = to_int(value) + self.res_h_max = to_int(value) + + @property + def res_min(self) -> list[Optional[int]]: + return [self.res_w_min, self.res_h_min] + + @res_min.setter + def res_min(self, value: Optional[int]): + self.res_w_min = to_int(value) + self.res_h_min = to_int(value) + @property def res_w(self) -> list[Optional[int]]: return [self.res_w_min, self.res_w_max] - + @res_w.setter def res_w(self, value: Optional[int]): self.res_w_min, self.res_w_max = to_int(value), to_int(value) - + @property def res_h(self) -> list[Optional[int]]: return [self.res_h_min, self.res_h_max] - + @res_h.setter def res_h(self, value: Optional[int]): self.res_h_min, self.res_h_max = to_int(value), to_int(value) - + @property def quality(self) -> list[Optional[int]]: return [self.quality_min, self.quality_max] - + @quality.setter def quality(self, value: Optional[int]): self.quality_min, self.quality_max = to_int(value), to_int(value) - + @property def color(self) -> list[Optional[int]]: return [self.color_min, self.color_max] - + @color.setter def color(self, value: Optional[int]): self.color_min, self.color_max = to_int(value), to_int(value) - + @property def duration(self) -> list[Optional[int]]: return [self.duration_min, self.duration_max] - + @duration.setter def duration(self, value: Optional[int]): self.duration_min, self.duration_max = to_int(value), to_int(value) - + +@dataclass class OutputOption(BaseOption): - def __init__(self, output_config_dict: dict): - self.option: Optional[str] = output_config_dict.get('option') - self.dir: Optional[Path] = Path(output_config_dict.get('dir')) - self.title: Optional[str] = output_config_dict.get('title') - self.author: Optional[str] = output_config_dict.get('author') - - def to_dict(self) -> dict: + option: str = "local" + dir: Path = Path() + title: str = "" + author: str = "" + + def to_dict(self) -> dict[Any, Any]: return { - 'option': self.option, - 'dir': self.dir.as_posix(), - 'title': self.title, - 'author': self.author + "option": self.option, + "dir": self.dir.as_posix(), + "title": self.title, + "author": self.author, } - + + +@dataclass class CredOption(BaseOption): - def __init__(self, cred_config_dict: dict): - self.signal_uuid: Optional[str] = cred_config_dict.get('signal', {}).get('uuid') - self.signal_password: Optional[str] = cred_config_dict.get('signal', {}).get('password') - self.telegram_token: Optional[str] = cred_config_dict.get('telegram', {}).get('token') - self.telegram_userid: Optional[str] = cred_config_dict.get('telegram', {}).get('userid') - self.kakao_auth_token: Optional[str] = cred_config_dict.get('kakao', {}).get('auth_token') - self.kakao_username: Optional[str] = cred_config_dict.get('kakao', {}).get('username') - self.kakao_password: Optional[str] = cred_config_dict.get('kakao', {}).get('password') - self.kakao_country_code: Optional[str] = cred_config_dict.get('kakao', {}).get('country_code') - self.kakao_phone_number: Optional[str] = cred_config_dict.get('kakao', {}).get('phone_number') - self.line_cookies: Optional[str] = cred_config_dict.get('line', {}).get('cookies') - - def to_dict(self) -> dict: + signal_uuid: str = "" + signal_password: str = "" + telegram_token: str = "" + telegram_userid: str = "" + kakao_auth_token: str = "" + kakao_username: str = "" + kakao_password: str = "" + kakao_country_code: str = "" + kakao_phone_number: str = "" + line_cookies: str = "" + + def to_dict(self) -> dict[Any, Any]: return { - 'signal': { - 'uuid': self.signal_uuid, - 'password': self.signal_password - }, - 'telegram': { - 'token': self.telegram_token, - 'userid': self.telegram_userid - }, - 'kakao': { - 'auth_token': self.kakao_auth_token, - 'username': self.kakao_username, - 'password': self.kakao_password, - 'country_code': self.kakao_country_code, - 'phone_number': self.kakao_phone_number + "signal": {"uuid": self.signal_uuid, "password": self.signal_password}, + "telegram": {"token": self.telegram_token, "userid": self.telegram_userid}, + "kakao": { + "auth_token": self.kakao_auth_token, + "username": self.kakao_username, + "password": self.kakao_password, + "country_code": self.kakao_country_code, + "phone_number": self.kakao_phone_number, }, - 'line': { - 'cookies': self.line_cookies - } + "line": {"cookies": self.line_cookies}, } diff --git a/src/sticker_convert/resources/compression.json b/src/sticker_convert/resources/compression.json index 3aaf48e..8d7e76f 100755 --- a/src/sticker_convert/resources/compression.json +++ b/src/sticker_convert/resources/compression.json @@ -461,7 +461,7 @@ "fps": { "min": 1, "max": 30, - "power": -0.5 + "power": -0.3 }, "res": { "w": { diff --git a/src/sticker_convert/uploaders/__init__.py b/src/sticker_convert/uploaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/sticker_convert/uploaders/compress_wastickers.py b/src/sticker_convert/uploaders/compress_wastickers.py index 074e038..7746498 100755 --- a/src/sticker_convert/uploaders/compress_wastickers.py +++ b/src/sticker_convert/uploaders/compress_wastickers.py @@ -2,59 +2,68 @@ import copy import shutil import zipfile -from multiprocessing.managers import BaseProxy from pathlib import Path -from typing import Optional, Union +from queue import Queue +from typing import Union, Optional, Any -from sticker_convert.converter import StickerConvert # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - OutputOption) -from sticker_convert.uploaders.upload_base import UploadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.cache_store import CacheStore # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.files.sanitize_filename import sanitize_filename # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore -from sticker_convert.utils.media.format_verify import FormatVerify # type: ignore +from sticker_convert.converter import StickerConvert +from sticker_convert.job_option import CompOption, CredOption, OutputOption +from sticker_convert.uploaders.upload_base import UploadBase +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.cache_store import CacheStore +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.files.sanitize_filename import sanitize_filename +from sticker_convert.utils.media.codec_info import CodecInfo +from sticker_convert.utils.media.format_verify import FormatVerify class CompressWastickers(UploadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(CompressWastickers, self).__init__(*args, **kwargs) - base_spec = CompOption({ - "size_max": {"img": 100000, "vid": 500000}, - "res": 512, - "duration": {"min": 8, "max": 10000}, - "format": ".webp", - "square": True, - }) - - self.spec_cover = CompOption({ - "size_max": {"img": 50000, "vid": 50000}, - "res": 96, - "fps": 0, - "format": ".png", - "animated": False, - }) + base_spec = CompOption( + size_max_img=100000, + size_max_vid=500000, + duration_min=8, + duration_max=10000, + square=True, + ) + base_spec.res = 512 + base_spec.format = [".webp"] + + self.spec_cover = CompOption( + size_max_img=50000, + size_max_vid=50000, + animated=False, + ) + self.spec_cover.res = 96 + self.spec_cover.res = 96 + self.spec_cover.fps = 0 + self.format = [".png"] self.webp_spec = copy.deepcopy(base_spec) - self.webp_spec.format = ".webp" + self.webp_spec.format = [".webp"] self.webp_spec.animated = None if self.opt_comp.fake_vid else True self.png_spec = copy.deepcopy(base_spec) - self.png_spec.format = ".png" + self.png_spec.format = [".png"] self.png_spec.animated = False self.opt_comp_merged = copy.deepcopy(self.opt_comp) self.opt_comp_merged.merge(base_spec) def compress_wastickers(self) -> list[str]: - urls = [] - title, author, emoji_dict = MetadataHandler.get_metadata( + urls: list[str] = [] + title, author, _ = MetadataHandler.get_metadata( self.opt_output.dir, title=self.opt_output.title, author=self.opt_output.author, ) + if not title: + self.cb.put("Title is required for compressing .wastickers") + return urls + if not author: + self.cb.put("Author is required for compressing .wastickers") + return urls packs = MetadataHandler.split_sticker_packs( self.opt_output.dir, title=title, @@ -64,9 +73,7 @@ def compress_wastickers(self) -> list[str]: for pack_title, stickers in packs.items(): # Originally the Sticker Maker application name the files with int(time.time()) - with CacheStore.get_cache_store( - path=self.opt_comp.cache_dir - ) as tempdir: + with CacheStore.get_cache_store(path=self.opt_comp.cache_dir) as tempdir: for num, src in enumerate(stickers): self.cb.put(f"Verifying {src} for compressing into .wastickers") @@ -77,17 +84,26 @@ def compress_wastickers(self) -> list[str]: dst = Path(tempdir, str(num) + ext) - if (FormatVerify.check_file(src, spec=self.webp_spec) or - FormatVerify.check_file(src, spec=self.png_spec)): + if FormatVerify.check_file( + src, spec=self.webp_spec + ) or FormatVerify.check_file(src, spec=self.png_spec): shutil.copy(src, dst) else: - StickerConvert.convert(Path(src), Path(dst), self.opt_comp_merged, self.cb, self.cb_return) - - out_f = Path(self.opt_output.dir, sanitize_filename(pack_title + ".wastickers")).as_posix() - - self.add_metadata(tempdir, pack_title, author) + StickerConvert.convert( + Path(src), + Path(dst), + self.opt_comp_merged, + self.cb, + self.cb_return, + ) + + out_f = Path( + self.opt_output.dir, sanitize_filename(pack_title + ".wastickers") + ).as_posix() + + self.add_metadata(Path(tempdir), pack_title, author) with zipfile.ZipFile(out_f, "w", zipfile.ZIP_DEFLATED) as zipf: - for file in tempdir.iterdir(): + for file in Path(tempdir).iterdir(): file_path = Path(tempdir, file) zipf.write(file_path, arcname=file_path.stem) @@ -107,15 +123,19 @@ def add_metadata(self, pack_dir: Path, title: str, author: str): shutil.copy(cover_path_old, cover_path_new) else: StickerConvert.convert( - cover_path_old, cover_path_new, opt_comp_merged, self.cb, self.cb_return + cover_path_old, + cover_path_new, + opt_comp_merged, + self.cb, + self.cb_return, ) else: # First image in the directory, extracting first frame first_image = [ i for i in sorted(self.opt_output.dir.iterdir()) - if Path(self.opt_output.dir, i).is_file() - and not i.endswith((".txt", ".m4a", ".wastickers")) + if Path(self.opt_output.dir, i.name).is_file() + and i.suffix not in (".txt", ".m4a", ".wastickers") ][0] StickerConvert.convert( Path(self.opt_output.dir, first_image), @@ -132,14 +152,17 @@ def start( opt_output: OutputOption, opt_comp: CompOption, opt_cred: CredOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> list[str]: - exporter = CompressWastickers( - opt_output, - opt_comp, - opt_cred, - cb, - cb_return - ) + exporter = CompressWastickers(opt_output, opt_comp, opt_cred, cb, cb_return) return exporter.compress_wastickers() diff --git a/src/sticker_convert/uploaders/upload_base.py b/src/sticker_convert/uploaders/upload_base.py index f57edf1..8dfe97b 100755 --- a/src/sticker_convert/uploaders/upload_base.py +++ b/src/sticker_convert/uploaders/upload_base.py @@ -1,11 +1,10 @@ #!/usr/bin/env python3 -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Union, Optional -from typing import Optional, Union +from sticker_convert.job_option import CompOption, CredOption, OutputOption +from sticker_convert.utils.callback import Callback, CallbackReturn -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - OutputOption) -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore class UploadBase: def __init__( @@ -13,8 +12,17 @@ def __init__( opt_output: OutputOption, opt_comp: CompOption, opt_cred: CredOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ): if not cb: cb = Callback(silent=True) @@ -24,4 +32,4 @@ def __init__( self.opt_comp = opt_comp self.opt_cred = opt_cred self.cb = cb - self.cb_return = cb_return \ No newline at end of file + self.cb_return = cb_return diff --git a/src/sticker_convert/uploaders/upload_signal.py b/src/sticker_convert/uploaders/upload_signal.py index 286466c..9bed768 100755 --- a/src/sticker_convert/uploaders/upload_signal.py +++ b/src/sticker_convert/uploaders/upload_signal.py @@ -1,41 +1,38 @@ #!/usr/bin/env python3 import copy from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Union, Any, Optional import anyio from signalstickers_client import StickersClient # type: ignore from signalstickers_client.errors import SignalException # type: ignore -from signalstickers_client.models import (LocalStickerPack, # type: ignore - Sticker) +from signalstickers_client.models import LocalStickerPack, Sticker # type: ignore -from sticker_convert.converter import StickerConvert # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - OutputOption) -from sticker_convert.uploaders.upload_base import UploadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore -from sticker_convert.utils.media.format_verify import FormatVerify # type: ignore +from sticker_convert.converter import StickerConvert +from sticker_convert.job_option import CompOption, CredOption, OutputOption +from sticker_convert.uploaders.upload_base import UploadBase +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.codec_info import CodecInfo +from sticker_convert.utils.media.format_verify import FormatVerify class UploadSignal(UploadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(UploadSignal, self).__init__(*args, **kwargs) - base_spec = CompOption({ - "size_max": {"img": 300000, "vid": 300000}, - "res": {"max": 512}, - "duration": {"max": 3000}, - "square": True, - }) + base_spec = CompOption() + base_spec.size_max = 300000 + base_spec.res_max = 512 + base_spec.duration_max = 3000 + base_spec.square = True self.png_spec = copy.deepcopy(base_spec) - self.png_spec.format = '.apng' + self.png_spec.format = [".apng"] self.webp_spec = copy.deepcopy(base_spec) - self.webp_spec.format_img = '.webp' + self.webp_spec.format_img = [".webp"] self.webp_spec.animated = False self.opt_comp_merged = copy.deepcopy(self.opt_comp) @@ -44,7 +41,7 @@ def __init__(self, *args, **kwargs): @staticmethod async def upload_pack(pack: LocalStickerPack, uuid: str, password: str): async with StickersClient(uuid, password) as client: - pack_id, pack_key = await client.upload_pack(pack) + pack_id, pack_key = await client.upload_pack(pack) # type: ignore result = ( f"https://signal.art/addstickers/#pack_id={pack_id}&pack_key={pack_key}" @@ -52,13 +49,13 @@ async def upload_pack(pack: LocalStickerPack, uuid: str, password: str): return result def add_stickers_to_pack( - self, pack: LocalStickerPack, stickers: list[str], emoji_dict: dict + self, pack: LocalStickerPack, stickers: list[Path], emoji_dict: dict[str, str] ): for src in stickers: self.cb.put(f"Verifying {src} for uploading to signal") sticker = Sticker() - sticker.id = pack.nb_stickers + sticker.id = pack.nb_stickers # type: ignore emoji = emoji_dict.get(Path(src).stem, None) if not emoji: @@ -66,7 +63,7 @@ def add_stickers_to_pack( f"Warning: Cannot find emoji for file {Path(src).name}, skip uploading this file..." ) continue - sticker.emoji = emoji[:1] + sticker.emoji = emoji[:1] # type: ignore if Path(src).suffix == ".webp": spec_choice = self.webp_spec @@ -78,17 +75,20 @@ def add_stickers_to_pack( dst = "bytes.apng" else: dst = "bytes.png" - _, _, sticker.image_data, _ = StickerConvert.convert( + _, _, image_data, _ = StickerConvert.convert( Path(src), Path(dst), self.opt_comp_merged, self.cb, self.cb_return ) + assert image_data + + sticker.image_data = image_data # type: ignore else: with open(src, "rb") as f: - sticker.image_data = f.read() + sticker.image_data = f.read() # type: ignore - pack._addsticker(sticker) + pack._addsticker(sticker) # type: ignore def upload_stickers_signal(self) -> list[str]: - urls = [] + urls: list[str] = [] if not self.opt_cred.signal_uuid: self.cb.put("uuid required for uploading to Signal") @@ -102,37 +102,42 @@ def upload_stickers_signal(self) -> list[str]: title=self.opt_output.title, author=self.opt_output.author, ) - if title == None: + if title is None: raise TypeError(f"title cannot be {title}") - if author == None: + if author is None: raise TypeError(f"author cannot be {author}") - if emoji_dict == None: + if emoji_dict is None: msg_block = "emoji.txt is required for uploading signal stickers\n" msg_block += f"emoji.txt generated for you in {self.opt_output.dir}\n" - msg_block += ( - f'Default emoji is set to {self.opt_comp.default_emoji}.\n' - ) + msg_block += f"Default emoji is set to {self.opt_comp.default_emoji}.\n" msg_block += "Please edit emoji.txt now, then continue" MetadataHandler.generate_emoji_file( dir=self.opt_output.dir, default_emoji=self.opt_comp.default_emoji ) - self.cb.put(("msg_block", (msg_block,))) - self.cb_return.get_response() + self.cb.put(("msg_block", (msg_block,), None)) + if self.cb_return: + self.cb_return.get_response() title, author, emoji_dict = MetadataHandler.get_metadata( self.opt_output.dir, title=self.opt_output.title, author=self.opt_output.author, ) + assert title + assert author + assert emoji_dict packs = MetadataHandler.split_sticker_packs( - self.opt_output.dir, title=title, file_per_pack=200, separate_image_anim=False + self.opt_output.dir, + title=title, + file_per_pack=200, + separate_image_anim=False, ) for pack_title, stickers in packs.items(): pack = LocalStickerPack() - pack.title = pack_title - pack.author = author + pack.title = pack_title # type: ignore + pack.author = author # type: ignore self.add_stickers_to_pack(pack, stickers, emoji_dict) self.cb.put(f"Uploading pack {pack_title}") @@ -157,15 +162,17 @@ def start( opt_output: OutputOption, opt_comp: CompOption, opt_cred: CredOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, - **kwargs, + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> list[str]: - exporter = UploadSignal( - opt_output, - opt_comp, - opt_cred, - cb, - cb_return - ) + exporter = UploadSignal(opt_output, opt_comp, opt_cred, cb, cb_return) return exporter.upload_stickers_signal() diff --git a/src/sticker_convert/uploaders/upload_telegram.py b/src/sticker_convert/uploaders/upload_telegram.py index 0019077..89c6279 100755 --- a/src/sticker_convert/uploaders/upload_telegram.py +++ b/src/sticker_convert/uploaders/upload_telegram.py @@ -2,70 +2,65 @@ import copy import re from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Union, Any, Optional import anyio from telegram import Bot, InputSticker, Sticker from telegram.error import TelegramError -from sticker_convert.converter import StickerConvert # type: ignore -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - OutputOption) -from sticker_convert.uploaders.upload_base import UploadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.media.format_verify import FormatVerify # type: ignore +from sticker_convert.converter import StickerConvert +from sticker_convert.job_option import CompOption, CredOption, OutputOption +from sticker_convert.uploaders.upload_base import UploadBase +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.media.format_verify import FormatVerify class UploadTelegram(UploadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(UploadTelegram, self).__init__(*args, **kwargs) - base_spec = CompOption({ - "size_max": {"img": 512000, "vid": 256000}, - "res": 512, - "square": True, - "duration": {"max": 3000} - }) + base_spec = CompOption( + size_max_img=512000, size_max_vid=256000, square=True, duration_max=3000 + ) + base_spec.res = 512 self.png_spec = copy.deepcopy(base_spec) - self.png_spec.format = ".png" + self.png_spec.format = [".png"] self.png_spec.animated = False self.tgs_spec = copy.deepcopy(base_spec) - self.tgs_spec.format = ".tgs" + self.tgs_spec.format = [".tgs"] self.tgs_spec.fps_min = 60 self.tgs_spec.fps_max = 60 self.tgs_spec.size_max_img = 64000 self.tgs_spec.size_max_vid = 64000 self.webm_spec = copy.deepcopy(base_spec) - self.webm_spec.format = ".webm" + self.webm_spec.format = [".webm"] self.webm_spec.fps_max = 30 self.webm_spec.animated = None if self.opt_comp.fake_vid else True self.opt_comp_merged = copy.deepcopy(self.opt_comp) self.opt_comp_merged.merge(base_spec) - base_cover_spec = CompOption({ - "size_max": {"img": 128000, "vid": 32000}, - "res": 100, - "square": True, - "duration": {"max": 3000} - }) + base_cover_spec = CompOption( + size_max_img=128000, size_max_vid=32000, square=True, duration_max=3000 + ) + base_cover_spec.res = 100 self.png_cover_spec = copy.deepcopy(base_cover_spec) - self.png_cover_spec.format = ".png" + self.png_cover_spec.format = [".png"] self.png_cover_spec.animated = False self.tgs_cover_spec = copy.deepcopy(base_cover_spec) - self.tgs_cover_spec.format = ".tgs" + self.tgs_cover_spec.format = [".tgs"] self.tgs_cover_spec.fps_min = 60 self.tgs_cover_spec.fps_max = 60 self.webm_cover_spec = copy.deepcopy(base_cover_spec) - self.webm_cover_spec.format = ".webm" + self.webm_cover_spec.format = [".webm"] self.webm_cover_spec.fps_max = 30 self.webm_cover_spec.animated = True @@ -73,8 +68,9 @@ def __init__(self, *args, **kwargs): self.opt_comp_cover_merged.merge(base_spec) async def upload_pack( - self, pack_title: str, stickers: list[str], emoji_dict: dict[str, str] + self, pack_title: str, stickers: list[Path], emoji_dict: dict[str, str] ) -> str: + assert self.opt_cred.telegram_token bot = Bot(self.opt_cred.telegram_token.strip()) async with bot: @@ -87,30 +83,39 @@ async def upload_pack( try: sticker_set = await bot.get_sticker_set( - pack_short_name, + pack_short_name, # type: ignore read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30, ) - await bot.get_sticker_set(pack_short_name) + await bot.get_sticker_set(pack_short_name) # type: ignore pack_exists = True except TelegramError: pack_exists = False - if pack_exists == True: - self.cb.put("ask_bool", - (f"Warning: Pack {pack_short_name} already exists.\nDelete all stickers in pack?",) + if pack_exists is True: + self.cb.put( + ( + "ask_bool", + ( + f"Warning: Pack {pack_short_name} already exists.\nDelete all stickers in pack?", + ), + None, + ) ) - response = self.cb_return.get_response() - if response == True: + if self.cb_return: + response = self.cb_return.get_response() + else: + response = False + if response is True: self.cb.put(f"Deleting all stickers from pack {pack_short_name}") try: - for i in sticker_set.stickers: + for i in sticker_set.stickers: # type: ignore await bot.delete_sticker_from_set(i.file_id) except TelegramError as e: self.cb.put( - f"Cannot delete sticker {i.file_id} from {pack_short_name} due to {e}" + f"Cannot delete sticker {i.file_id} from {pack_short_name} due to {e}" # type: ignore ) else: self.cb.put(f"Not deleting existing pack {pack_short_name}") @@ -130,7 +135,7 @@ async def upload_pack( f"Warning: Cannot find emoji for file {Path(src).name}, skip uploading this file..." ) continue - + ext = Path(src).suffix if ext == ".tgs": spec_choice = self.tgs_spec @@ -145,7 +150,7 @@ async def upload_pack( spec_choice = self.png_spec cover_spec_choice = self.png_cover_spec sticker_format = "static" - + if self.opt_output.option == "telegram_emoji": sticker_type = Sticker.CUSTOM_EMOJI spec_choice.res = 100 @@ -157,28 +162,32 @@ async def upload_pack( sticker_bytes = f.read() else: _, _, sticker_bytes, _ = StickerConvert.convert( - Path(src), Path(f"bytes{ext}"), self.opt_comp_merged, self.cb, self.cb_return + Path(src), + Path(f"bytes{ext}"), + self.opt_comp_merged, + self.cb, + self.cb_return, ) - sticker = InputSticker(sticker=sticker_bytes, emoji_list=emoji_list) + sticker = InputSticker(sticker=sticker_bytes, emoji_list=emoji_list) # type: ignore try: - if pack_exists == False: + if pack_exists is False: await bot.create_new_sticker_set( user_id=self.opt_cred.telegram_userid, name=pack_short_name, title=pack_title, stickers=[sticker], sticker_format=sticker_format, - sticker_type=sticker_type - ) + sticker_type=sticker_type, + ) # type: ignore pack_exists = True else: await bot.add_sticker_to_set( user_id=self.opt_cred.telegram_userid, name=pack_short_name, sticker=sticker, - ) + ) # type: ignore except TelegramError as e: self.cb.put( f"Cannot upload sticker {src} in {pack_short_name} due to {e}" @@ -189,13 +198,13 @@ async def upload_pack( cover_path = MetadataHandler.get_cover(self.opt_output.dir) if cover_path: - if FormatVerify.check_file(cover_path, spec=cover_spec_choice): + if FormatVerify.check_file(cover_path, spec=cover_spec_choice): # type: ignore with open(cover_path, "rb") as f: thumbnail_bytes = f.read() else: - _, _, thumbnail_bytes, _ = StickerConvert.convert( + _, _, thumbnail_bytes, _ = StickerConvert.convert( # type: ignore cover_path, - Path(f"bytes{ext}"), + Path(f"bytes{ext}"), # type: ignore self.opt_comp_cover_merged, self.cb, ) @@ -205,7 +214,7 @@ async def upload_pack( name=pack_short_name, user_id=self.opt_cred.telegram_userid, thumbnail=thumbnail_bytes, - ) + ) # type: ignore except TelegramError as e: self.cb.put( f"Cannot upload cover (thumbnail) for {pack_short_name} due to {e}" @@ -218,34 +227,33 @@ async def upload_pack( return result def upload_stickers_telegram(self) -> list[str]: - urls = [] + urls: list[str] = [] if not (self.opt_cred.telegram_token and self.opt_cred.telegram_userid): self.cb.put("Token and userid required for uploading to telegram") return urls - title, author, emoji_dict = MetadataHandler.get_metadata( + title, _, emoji_dict = MetadataHandler.get_metadata( self.opt_output.dir, title=self.opt_output.title, author=self.opt_output.author, ) - if title == None: + if title is None: raise TypeError("title cannot be", title) - if emoji_dict == None: + if emoji_dict is None: msg_block = "emoji.txt is required for uploading signal stickers\n" msg_block += f"emoji.txt generated for you in {self.opt_output.dir}\n" - msg_block += ( - f'Default emoji is set to {self.opt_comp.default_emoji}.\n' - ) + msg_block += f"Default emoji is set to {self.opt_comp.default_emoji}.\n" msg_block += "Please edit emoji.txt now, then continue" MetadataHandler.generate_emoji_file( dir=self.opt_output.dir, default_emoji=self.opt_comp.default_emoji ) - self.cb.put(("msg_block", (msg_block,))) - self.cb_return.get_response() + self.cb.put(("msg_block", (msg_block,), None)) + if self.cb_return: + self.cb_return.get_response() - title, author, emoji_dict = MetadataHandler.get_metadata( + title, _, emoji_dict = MetadataHandler.get_metadata( self.opt_output.dir, title=self.opt_output.title, author=self.opt_output.author, @@ -253,7 +261,7 @@ def upload_stickers_telegram(self) -> list[str]: packs = MetadataHandler.split_sticker_packs( self.opt_output.dir, - title=title, + title=title, # type: ignore file_per_anim_pack=50, file_per_image_pack=120, separate_image_anim=not self.opt_comp.fake_vid, @@ -272,9 +280,17 @@ def start( opt_output: OutputOption, opt_comp: CompOption, opt_cred: CredOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, - **kwargs, + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> list[str]: exporter = UploadTelegram( opt_output, diff --git a/src/sticker_convert/uploaders/xcode_imessage.py b/src/sticker_convert/uploaders/xcode_imessage.py index c861878..81f4aed 100755 --- a/src/sticker_convert/uploaders/xcode_imessage.py +++ b/src/sticker_convert/uploaders/xcode_imessage.py @@ -6,19 +6,18 @@ import shutil import zipfile from pathlib import Path -from typing import Optional, Union -from multiprocessing.managers import BaseProxy +from queue import Queue +from typing import Union, Any, Optional -from sticker_convert.converter import StickerConvert # type: ignore +from sticker_convert.converter import StickerConvert from sticker_convert.definitions import ROOT_DIR -from sticker_convert.job_option import (CompOption, CredOption, # type: ignore - OutputOption) -from sticker_convert.uploaders.upload_base import UploadBase # type: ignore -from sticker_convert.utils.callback import Callback, CallbackReturn # type: ignore -from sticker_convert.utils.files.metadata_handler import MetadataHandler # type: ignore -from sticker_convert.utils.files.sanitize_filename import sanitize_filename # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore -from sticker_convert.utils.media.format_verify import FormatVerify # type: ignore +from sticker_convert.job_option import CompOption, CredOption, OutputOption +from sticker_convert.uploaders.upload_base import UploadBase +from sticker_convert.utils.callback import Callback, CallbackReturn +from sticker_convert.utils.files.metadata_handler import MetadataHandler +from sticker_convert.utils.files.sanitize_filename import sanitize_filename +from sticker_convert.utils.media.codec_info import CodecInfo +from sticker_convert.utils.media.format_verify import FormatVerify class XcodeImessageIconset: @@ -30,11 +29,14 @@ def __init__(self): if (ROOT_DIR / "ios-message-stickers-template").is_dir(): with open( - ROOT_DIR / "ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset/Contents.json" + ROOT_DIR + / "ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset/Contents.json" ) as f: dict = json.load(f) elif (ROOT_DIR / "ios-message-stickers-template.zip").is_file(): - with zipfile.ZipFile((ROOT_DIR / "ios-message-stickers-template.zip"), "r") as f: + with zipfile.ZipFile( + (ROOT_DIR / "ios-message-stickers-template.zip"), "r" + ) as f: dict = json.loads( f.read( "stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset/Contents.json" @@ -72,16 +74,15 @@ def __init__(self): class XcodeImessage(UploadBase): - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any): super(XcodeImessage, self).__init__(*args, **kwargs) self.iconset = XcodeImessageIconset().iconset - base_spec = CompOption({ - "size_max": {"img": 500000, "vid": 500000}, - "res": 300, - "format": [".png", ".apng", ".gif", ".jpeg", "jpg"], - "square": True, - }) + base_spec = CompOption() + base_spec.size_max = 500000 + base_spec.res = 300 + base_spec.format = ["png", ".apng", ".gif", ".jpeg", "jpg"] + base_spec.square = True self.small_spec = copy.deepcopy(base_spec) @@ -92,19 +93,31 @@ def __init__(self, *args, **kwargs): self.large_spec.res = 618 def create_imessage_xcode(self) -> list[str]: - urls = [] - title, author, emoji_dict = MetadataHandler.get_metadata( + urls: list[str] = [] + title, author, _ = MetadataHandler.get_metadata( self.opt_output.dir, title=self.opt_output.title, author=self.opt_output.author, ) + if not author: + self.cb.put("author is required for creating Xcode iMessage sticker pack") + return urls + if not title: + self.cb.put("title is required for creating Xcode iMessage sticker pack") + return urls + author = author.replace(" ", "_") title = title.replace(" ", "_") packs = MetadataHandler.split_sticker_packs( - self.opt_output.dir, title=title, file_per_pack=100, separate_image_anim=False + self.opt_output.dir, + title=title, + file_per_pack=100, + separate_image_anim=False, ) res_choice = None + spec_choice = None + opt_comp_merged = copy.deepcopy(self.opt_comp) for pack_title, stickers in packs.items(): pack_title = sanitize_filename(pack_title) @@ -114,9 +127,8 @@ def create_imessage_xcode(self) -> list[str]: fpath = Path(self.opt_output.dir, src) - if res_choice == None: + if res_choice is None: res_choice, _ = CodecInfo.get_file_res(fpath) - res_choice = res_choice if res_choice != None else 300 if res_choice == 618: spec_choice = self.large_spec @@ -126,11 +138,13 @@ def create_imessage_xcode(self) -> list[str]: # res_choice == 300 spec_choice = self.small_spec - opt_comp_merged = copy.deepcopy(self.opt_comp) opt_comp_merged.merge(spec_choice) + assert spec_choice if not FormatVerify.check_file(src, spec=spec_choice): - StickerConvert.convert(fpath, fpath, opt_comp_merged, self.cb, self.cb_return) + StickerConvert.convert( + fpath, fpath, opt_comp_merged, self.cb, self.cb_return + ) self.add_metadata(author, pack_title) self.create_xcode_proj(author, pack_title) @@ -142,12 +156,13 @@ def create_imessage_xcode(self) -> list[str]: return urls def add_metadata(self, author: str, title: str): - first_image_path = Path(self.opt_output.dir, + first_image_path = Path( + self.opt_output.dir, [ i for i in sorted(self.opt_output.dir.iterdir()) - if (self.opt_output.dir / i).is_file() and i.endswith(".png") - ][0] + if (self.opt_output.dir / i).is_file() and i.suffix == ".png" + ][0], ) cover_path = MetadataHandler.get_cover(self.opt_output.dir) if cover_path: @@ -156,28 +171,31 @@ def add_metadata(self, author: str, title: str): icon_source = first_image_path for icon, res in self.iconset.items(): - spec_cover = CompOption({ - "res": { - "w": res[0], - "h": res[1], - "fps": 0 - } - }) + spec_cover = CompOption() + spec_cover.res_w = res[0] + spec_cover.res_h = res[1] + spec_cover.fps = 0 icon_path = self.opt_output.dir / icon - if icon in self.opt_output.dir.iterdir() and not FormatVerify.check_file( - icon_path, spec=spec_cover - ): - StickerConvert.convert(icon_path, icon_path, spec_cover, self.cb, self.cb_return) + if Path(icon) in [ + i for i in self.opt_output.dir.iterdir() + ] and not FormatVerify.check_file(icon_path, spec=spec_cover): + StickerConvert.convert( + icon_path, icon_path, spec_cover, self.cb, self.cb_return + ) else: - StickerConvert.convert(icon_source, icon_path, spec_cover, self.cb, self.cb_return) + StickerConvert.convert( + icon_source, icon_path, spec_cover, self.cb, self.cb_return + ) MetadataHandler.set_metadata(self.opt_output.dir, author=author, title=title) def create_xcode_proj(self, author: str, title: str): pack_path = self.opt_output.dir / title if (ROOT_DIR / "ios-message-stickers-template.zip").is_file(): - with zipfile.ZipFile(ROOT_DIR / "ios-message-stickers-template.zip", "r") as f: + with zipfile.ZipFile( + ROOT_DIR / "ios-message-stickers-template.zip", "r" + ) as f: f.extractall(pack_path) elif (ROOT_DIR / "ios-message-stickers-template").is_dir(): shutil.copytree(ROOT_DIR / "ios-message-stickers-template", pack_path) @@ -191,9 +209,7 @@ def create_xcode_proj(self, author: str, title: str): pack_path / "stickers.xcodeproj/project.xcworkspace", ignore_errors=True, ) - shutil.rmtree( - pack_path / "stickers.xcodeproj/xcuserdata", ignore_errors=True - ) + shutil.rmtree(pack_path / "stickers.xcodeproj/xcuserdata", ignore_errors=True) with open( pack_path / "stickers.xcodeproj/project.pbxproj", @@ -235,33 +251,36 @@ def create_xcode_proj(self, author: str, title: str): f.write(pbxproj_data) # packname StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack - stickers_path = pack_path / "stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack" + stickers_path = ( + pack_path + / "stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack" + ) for i in stickers_path.iterdir(): - if i.endswith(".sticker"): + if i.suffix == ".sticker": shutil.rmtree(stickers_path / i) - stickers_lst = [] + stickers_lst: list[str] = [] for i in sorted(self.opt_output.dir.iterdir()): if ( CodecInfo.get_file_ext(i) == ".png" - and Path(i).stem != "cover" - and i not in self.iconset + and i.stem != "cover" + and i.name not in self.iconset ): - sticker_dir = f"{Path(i).stem}.sticker" # 0.sticker + sticker_dir = f"{i.stem}.sticker" # 0.sticker stickers_lst.append(sticker_dir) # packname StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/0.sticker sticker_path = stickers_path / sticker_dir os.mkdir(sticker_path) # packname StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/0.sticker/0.png - shutil.copy(self.opt_output.dir / i, sticker_path / i) + shutil.copy(self.opt_output.dir / i.name, sticker_path / i.name) json_content = { "info": { "author": "xcode", "version": 1, }, - "properties": {"filename": i}, + "properties": {"filename": str(i)}, } # packname StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/0.sticker/Contents.json @@ -274,19 +293,22 @@ def create_xcode_proj(self, author: str, title: str): json_content["stickers"] = [] for i in stickers_lst: - json_content["stickers"].append({"filename": i}) # type: ignore[attr-defined] + json_content["stickers"].append({"filename": i}) with open(stickers_path / "Contents.json", "w+") as f: json.dump(json_content, f, indent=2) # packname StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset - iconset_path = pack_path / "stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset" + iconset_path = ( + pack_path + / "stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset" + ) for i in iconset_path.iterdir(): if Path(i).suffix == ".png": os.remove(iconset_path / i) - icons_lst = [] + icons_lst: list[str] = [] for i in self.iconset: shutil.copy(self.opt_output.dir / i, iconset_path / i) icons_lst.append(i) @@ -301,23 +323,29 @@ def create_xcode_proj(self, author: str, title: str): plistlib.dump(plist_dict, f) Path(pack_path, "stickers").rename(Path(pack_path, title)) - Path(pack_path, "stickers StickerPackExtension").rename(Path(pack_path, f"{title} StickerPackExtension")) - Path(pack_path, "stickers.xcodeproj").rename(Path(pack_path, f"{title}.xcodeproj")) + Path(pack_path, "stickers StickerPackExtension").rename( + Path(pack_path, f"{title} StickerPackExtension") + ) + Path(pack_path, "stickers.xcodeproj").rename( + Path(pack_path, f"{title}.xcodeproj") + ) @staticmethod def start( opt_output: OutputOption, opt_comp: CompOption, opt_cred: CredOption, - cb: Union[BaseProxy, Callback, None] = None, - cb_return: Optional[CallbackReturn] = None, - **kwargs, + cb: Union[ + Queue[ + Union[ + tuple[str, Optional[tuple[str]], Optional[dict[str, str]]], + str, + None, + ] + ], + Callback, + ], + cb_return: CallbackReturn, ) -> list[str]: - exporter = XcodeImessage( - opt_output, - opt_comp, - opt_cred, - cb, - cb_return - ) + exporter = XcodeImessage(opt_output, opt_comp, opt_cred, cb, cb_return) return exporter.create_imessage_xcode() diff --git a/src/sticker_convert/utils/auth/get_kakao_auth.py b/src/sticker_convert/utils/auth/get_kakao_auth.py index fcba4a1..ef98236 100755 --- a/src/sticker_convert/utils/auth/get_kakao_auth.py +++ b/src/sticker_convert/utils/auth/get_kakao_auth.py @@ -2,7 +2,7 @@ import json import secrets import uuid -from typing import Optional +from typing import Optional, Callable, Any from urllib.parse import parse_qs, urlparse import requests @@ -11,7 +11,13 @@ class GetKakaoAuth: - def __init__(self, opt_cred: CredOption, cb_msg=print, cb_msg_block=input, cb_ask_str=input): + def __init__( + self, + opt_cred: CredOption, + cb_msg: Callable[..., None] = print, + cb_msg_block: Callable[..., Any] = input, + cb_ask_str: Callable[..., str] = input, + ): self.username = opt_cred.kakao_username self.password = opt_cred.kakao_password self.country_code = opt_cred.kakao_country_code @@ -24,224 +30,256 @@ def __init__(self, opt_cred: CredOption, cb_msg=print, cb_msg_block=input, cb_as self.device_uuid = secrets.token_hex(32) self.device_ssaid = secrets.token_hex(20) self.uuid_c = str(uuid.uuid4()) - self.device_info = f'android/30; uuid={self.device_uuid}; ssaid={self.device_ssaid}; ' + 'model=ANDROID-SDK-BUILT-FOR-X86; screen_resolution=1080x1920; sim=310260/1/us; onestore=false; uvc2={"volume":5,"network_operator":"310260","is_roaming":"false","va":[],"brightness":102,"totalMemory":30866040,"batteryPct":1,"webviewVersion":"83.0.4103.106"}' - self.app_platform = 'android' - self.app_version_number = '10.0.3' - self.app_language = 'en' - self.app_version = f'{self.app_platform}/{self.app_version_number}/{self.app_language}' + self.device_info = ( + f"android/30; uuid={self.device_uuid}; ssaid={self.device_ssaid}; " + + 'model=ANDROID-SDK-BUILT-FOR-X86; screen_resolution=1080x1920; sim=310260/1/us; onestore=false; uvc2={"volume":5,"network_operator":"310260","is_roaming":"false","va":[],"brightness":102,"totalMemory":30866040,"batteryPct":1,"webviewVersion":"83.0.4103.106"}' + ) + self.app_platform = "android" + self.app_version_number = "10.0.3" + self.app_language = "en" + self.app_version = ( + f"{self.app_platform}/{self.app_version_number}/{self.app_language}" + ) self.headers = { - 'Host': 'katalk.kakao.com', - 'Accept-Language': 'en', - 'User-Agent': 'KT/10.0.3 An/11 en', - 'Device-Info': self.device_info, - 'A': self.app_version, - 'C': self.uuid_c, - 'Content-Type': 'application/json', - 'Connection': 'close', + "Host": "katalk.kakao.com", + "Accept-Language": "en", + "User-Agent": "KT/10.0.3 An/11 en", + "Device-Info": self.device_info, + "A": self.app_version, + "C": self.uuid_c, + "Content-Type": "application/json", + "Connection": "close", } def login(self) -> bool: - self.cb_msg('Logging in') + self.cb_msg("Logging in") json_data = { - 'id': self.username, - 'password': self.password, + "id": self.username, + "password": self.password, } - response = requests.post('https://katalk.kakao.com/android/account2/login', headers=self.headers, json=json_data) + response = requests.post( + "https://katalk.kakao.com/android/account2/login", + headers=self.headers, + json=json_data, + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at login: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at login: {response.text}") return False - self.headers['Ss'] = response.headers['Set-SS'] - self.country_dicts = response_json['viewData']['countries']['all'] + self.headers["Ss"] = response.headers["Set-SS"] + self.country_dicts = response_json["viewData"]["countries"]["all"] return True def get_country_iso(self) -> bool: self.country_iso = None for country_dict in self.country_dicts: - if country_dict['code'] == self.country_code: - self.country_iso = country_dict['iso'] + if country_dict["code"] == self.country_code: + self.country_iso = country_dict["iso"] if not self.country_iso: - self.cb_msg_block('Invalid country code') + self.cb_msg_block("Invalid country code") return False return True def enter_phone(self) -> bool: - self.cb_msg('Submitting phone number') - - json_data = { - 'countryCode': self.country_code, - 'countryIso': self.country_iso, - 'phoneNumber': self.phone_number, - 'method': 'sms', - 'termCodes': [], - 'simPhoneNumber': f'+{self.country_code}{self.phone_number}', + self.cb_msg("Submitting phone number") + + json_data: dict[str, Any] = { + "countryCode": self.country_code, + "countryIso": self.country_iso, + "phoneNumber": self.phone_number, + "method": "sms", + "termCodes": [], + "simPhoneNumber": f"+{self.country_code}{self.phone_number}", } - response = requests.post('https://katalk.kakao.com/android/account2/phone-number', headers=self.headers, json=json_data) + response = requests.post( + "https://katalk.kakao.com/android/account2/phone-number", + headers=self.headers, + json=json_data, + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at entering phone number: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at entering phone number: {response.text}") return False - - self.verify_method = response_json['view'] - if self.verify_method == 'passcode': + self.verify_method = response_json["view"] + + if self.verify_method == "passcode": return self.verify_receive_sms() - elif self.verify_method == 'mo-send': - dest_number = response_json['viewData']['moNumber'] - msg = response_json['viewData']['moMessage'] + elif self.verify_method == "mo-send": + dest_number = response_json["viewData"]["moNumber"] + msg = response_json["viewData"]["moMessage"] return self.verify_send_sms(dest_number, msg) else: - self.cb_msg_block(f'Unknown verification method: {response.text}') + self.cb_msg_block(f"Unknown verification method: {response.text}") return False - + def verify_send_sms(self, dest_number: str, msg: str) -> bool: - self.cb_msg('Verification by sending SMS') + self.cb_msg("Verification by sending SMS") - response = requests.post('https://katalk.kakao.com/android/account2/mo-sent', headers=self.headers) + response = requests.post( + "https://katalk.kakao.com/android/account2/mo-sent", headers=self.headers + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at confirm sending SMS: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at confirm sending SMS: {response.text}") return False - prompt = f'Send this SMS message to number {dest_number} then press enter:' + prompt = f"Send this SMS message to number {dest_number} then press enter:" self.cb_msg(msg) if self.cb_ask_str != input: self.cb_ask_str(prompt, initialvalue=msg, cli_show_initialvalue=False) else: input(prompt) - response = requests.post('https://katalk.kakao.com/android/account2/mo-confirm', headers=self.headers) + response = requests.post( + "https://katalk.kakao.com/android/account2/mo-confirm", headers=self.headers + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at verifying SMS sent: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at verifying SMS sent: {response.text}") return False - - if response_json.get('reason') or 'error' in response_json.get('message', ''): - self.cb_msg_block(f'Failed at verifying SMS sent: {response.text}') + + if response_json.get("reason") or "error" in response_json.get("message", ""): + self.cb_msg_block(f"Failed at verifying SMS sent: {response.text}") return False - - self.confirm_url = response_json.get('viewData', {}).get('url') + + self.confirm_url = response_json.get("viewData", {}).get("url") return True def verify_receive_sms(self) -> bool: - self.cb_msg('Verification by receiving SMS') + self.cb_msg("Verification by receiving SMS") - passcode = self.cb_ask_str('Enter passcode received from SMS:') + passcode = self.cb_ask_str("Enter passcode received from SMS:") json_data = { - 'passcode': passcode, + "passcode": passcode, } - response = requests.post('https://katalk.kakao.com/android/account2/passcode', headers=self.headers, json=json_data) + response = requests.post( + "https://katalk.kakao.com/android/account2/passcode", + headers=self.headers, + json=json_data, + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at verifying passcode: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at verifying passcode: {response.text}") return False - self.confirm_url = response_json.get('viewData', {}).get('url') + self.confirm_url = response_json.get("viewData", {}).get("url") return True def confirm_device_change(self) -> bool: - self.cb_msg('Confirm device change') + self.cb_msg("Confirm device change") - confirm_url_parsed = urlparse(self.confirm_url) - confirm_url_qs = parse_qs(confirm_url_parsed.query) - session_token = confirm_url_qs['sessionToken'][0] + confirm_url_parsed = urlparse(self.confirm_url) # type: ignore + confirm_url_qs = parse_qs(confirm_url_parsed.query) # type: ignore + session_token: str = confirm_url_qs["sessionToken"][0] # type: ignore headers_browser = { - 'Host': 'katalk.kakao.com', - 'Accept': '*/*', - 'X-Requested-With': 'XMLHttpRequest', - 'User-Agent': 'Mozilla/5.0 (Linux; Android 11; Android SDK built for x86 Build/RSR1.210210.001.A1; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36;KAKAOTALK 2410030', - 'Content-Type': 'application/json', - 'Origin': 'https://katalk.kakao.com', - 'Sec-Fetch-Site': 'same-origin', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Dest': 'empty', - 'Referer': self.confirm_url, - 'Accept-Language': 'en-US,en;q=0.9', - 'Connection': 'close', + "Host": "katalk.kakao.com", + "Accept": "*/*", + "X-Requested-With": "XMLHttpRequest", + "User-Agent": "Mozilla/5.0 (Linux; Android 11; Android SDK built for x86 Build/RSR1.210210.001.A1; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/83.0.4103.106 Mobile Safari/537.36;KAKAOTALK 2410030", + "Content-Type": "application/json", + "Origin": "https://katalk.kakao.com", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Dest": "empty", + "Referer": self.confirm_url, + "Accept-Language": "en-US,en;q=0.9", + "Connection": "close", } - json_data = { - 'decision': 'continue', - 'lang': self.app_language, - 'sessionToken': session_token, - 'appVersion': self.app_version_number, + json_data: dict[str, str] = { + "decision": "continue", + "lang": self.app_language, + "sessionToken": session_token, + "appVersion": self.app_version_number, } - response = requests.post('https://katalk.kakao.com/android/account2/confirm-device-change', headers=headers_browser, json=json_data) + response = requests.post( + "https://katalk.kakao.com/android/account2/confirm-device-change", + headers=headers_browser, + json=json_data, + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at confirm device change: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at confirm device change: {response.text}") return False - + return True def passcode_callback(self) -> bool: - self.cb_msg('Passcode callback') + self.cb_msg("Passcode callback") - response = requests.get('https://katalk.kakao.com/android/account2/passcode/callback', headers=self.headers) + response = requests.get( + "https://katalk.kakao.com/android/account2/passcode/callback", + headers=self.headers, + ) response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at passcode callback: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at passcode callback: {response.text}") return False - self.nickname = response_json.get('viewData', {}).get('nickname') - - if self.nickname == None: - self.cb_msg_block(f'Failed at passcode callback: {response.text}') + self.nickname = response_json.get("viewData", {}).get("nickname") + + if self.nickname is None: + self.cb_msg_block(f"Failed at passcode callback: {response.text}") return False return True def get_profile(self) -> bool: - self.cb_msg('Get profile') - + self.cb_msg("Get profile") + json_data = { - 'nickname': self.nickname, - 'profileImageFlag': 1, - 'friendAutomation': True, + "nickname": self.nickname, + "profileImageFlag": 1, + "friendAutomation": True, } - response = requests.post('https://katalk.kakao.com/android/account2/profile', headers=self.headers, json=json_data) - + response = requests.post( + "https://katalk.kakao.com/android/account2/profile", + headers=self.headers, + json=json_data, + ) + response_json = json.loads(response.text) - if response_json['status'] != 0: - self.cb_msg_block(f'Failed at get profile: {response.text}') + if response_json["status"] != 0: + self.cb_msg_block(f"Failed at get profile: {response.text}") return False - self.access_token = response_json['signupData']['oauth2Token']['accessToken'] + self.access_token = response_json["signupData"]["oauth2Token"]["accessToken"] return True - + def get_cred(self) -> Optional[str]: - self.cb_msg('Get authorization token') + self.cb_msg("Get authorization token") authorization_token = None @@ -251,13 +289,13 @@ def get_cred(self) -> Optional[str]: self.enter_phone, self.confirm_device_change, self.passcode_callback, - self.get_profile + self.get_profile, ) - + for step in steps: success = step() if not success: return None - authorization_token = self.access_token + '-' + self.device_uuid - return authorization_token \ No newline at end of file + authorization_token = self.access_token + "-" + self.device_uuid + return authorization_token diff --git a/src/sticker_convert/utils/auth/get_line_auth.py b/src/sticker_convert/utils/auth/get_line_auth.py index 78db0ae..b3baa96 100755 --- a/src/sticker_convert/utils/auth/get_line_auth.py +++ b/src/sticker_convert/utils/auth/get_line_auth.py @@ -1,78 +1,81 @@ #!/usr/bin/env python3 import json import platform -from typing import Optional +from http.cookiejar import CookieJar +from typing import Optional, Union, Callable, Any import requests -import rookiepy # type: ignore +import rookiepy class GetLineAuth: def get_cred(self) -> Optional[str]: - browsers = [ - rookiepy.load, # Supposed to load from any browser, but may fail - rookiepy.firefox, - rookiepy.libre_wolf, - rookiepy.chrome, - rookiepy.chromium, - rookiepy.brave, - rookiepy.edge, - rookiepy.opera, - rookiepy.vivaldi + browsers: list[Callable[..., Any]] = [ + rookiepy.load, # type: ignore # Supposed to load from any browser, but may fail + rookiepy.firefox, # type: ignore + rookiepy.libre_wolf, # type: ignore + rookiepy.chrome, # type: ignore + rookiepy.chromium, # type: ignore + rookiepy.brave, # type: ignore + rookiepy.edge, # type: ignore + rookiepy.opera, # type: ignore + rookiepy.vivaldi, # type: ignore ] - if platform.system() == 'Windows': - browsers.extend([ - rookiepy.opera_gx, - rookiepy.internet_explorer, - ]) - elif platform.system() == 'Darwin': - browsers.extend([ - rookiepy.opera_gx, - rookiepy.safari, - ]) + if platform.system() == "Windows": + browsers.extend( + [ + rookiepy.opera_gx, # type: ignore + rookiepy.internet_explorer, # type: ignore + ] + ) + elif platform.system() == "Darwin": + browsers.extend( + [ + rookiepy.opera_gx, # type: ignore + rookiepy.safari, # type: ignore + ] + ) cookies_dict = None cookies_jar = None for browser in browsers: try: - cookies_dict = browser(['store.line.me']) - cookies_jar = rookiepy.to_cookiejar(cookies_dict) + cookies_dict = browser(["store.line.me"]) + cookies_jar = rookiepy.to_cookiejar(cookies_dict) # type: ignore if GetLineAuth.validate_cookies(cookies_jar): break except Exception: continue - - if cookies_jar == None: + + if cookies_dict is None or cookies_jar is None: return None - - cookies_list = ['%s=%s' % (i['name'], i['value']) for i in cookies_dict] - cookies = ';'.join(cookies_list) + + cookies_list = ["%s=%s" % (i["name"], i["value"]) for i in cookies_dict] + cookies = ";".join(cookies_list) return cookies - + @staticmethod - def validate_cookies(cookies: str) -> bool: + def validate_cookies(cookies: Union[CookieJar, dict[str, str]]) -> bool: headers = { - 'x-requested-with': 'XMLHttpRequest', + "x-requested-with": "XMLHttpRequest", } - params = { - 'text': 'test' - } + params = {"text": "test"} response = requests.get( - 'https://store.line.me/api/custom-sticker/validate/13782/en', + "https://store.line.me/api/custom-sticker/validate/13782/en", params=params, - cookies=cookies, # type: ignore[arg-type] + cookies=cookies, # type: ignore headers=headers, ) response_dict = json.loads(response.text) - if response_dict['errorMessage']: + if response_dict["errorMessage"]: return False else: - return True \ No newline at end of file + return True diff --git a/src/sticker_convert/utils/auth/get_signal_auth.py b/src/sticker_convert/utils/auth/get_signal_auth.py index 0e9303b..42dde43 100755 --- a/src/sticker_convert/utils/auth/get_signal_auth.py +++ b/src/sticker_convert/utils/auth/get_signal_auth.py @@ -9,20 +9,20 @@ import webbrowser import zipfile from pathlib import Path -from typing import Generator, Optional +from typing import Generator, Optional, Callable import requests from selenium import webdriver from selenium.common.exceptions import JavascriptException from selenium.webdriver.chrome.service import Service -from sticker_convert.definitions import CONFIG_DIR # type: ignore -from sticker_convert.utils.files.run_bin import RunBin # type: ignore +from sticker_convert.definitions import CONFIG_DIR +from sticker_convert.utils.files.run_bin import RunBin # https://stackoverflow.com/a/17197027 def strings(filename: str, min: int = 4) -> Generator[str, None, None]: - with open(filename, errors="ignore") as f: + with open(filename, "r", errors="ignore") as f: result = "" for c in f.read(): if c in string.printable: @@ -34,9 +34,15 @@ def strings(filename: str, min: int = 4) -> Generator[str, None, None]: if len(result) >= min: # catch result at EOF yield result + class GetSignalAuth: - def __init__(self, signal_bin_version: str = 'beta', cb_msg=print, cb_ask_str=input): - chromedriver_download_dir = CONFIG_DIR / 'bin' + def __init__( + self, + signal_bin_version: str = "beta", + cb_msg: Callable[..., None] = print, + cb_ask_str: Callable[..., str] = input, + ): + chromedriver_download_dir = CONFIG_DIR / "bin" os.makedirs(chromedriver_download_dir, exist_ok=True) self.signal_bin_version = signal_bin_version @@ -50,127 +56,150 @@ def download_signal_desktop(self, download_url: str, signal_bin_path: str): self.cb_msg(download_url) - prompt = 'Signal Desktop not detected.\n' - prompt += 'Download and install Signal Desktop BETA version\n' - prompt += 'After installation, quit Signal Desktop before continuing' + prompt = "Signal Desktop not detected.\n" + prompt += "Download and install Signal Desktop BETA version\n" + prompt += "After installation, quit Signal Desktop before continuing" while not (Path(signal_bin_path).is_file() or shutil.which(signal_bin_path)): if self.cb_ask_str != input: - self.cb_ask_str(prompt, initialvalue=download_url, cli_show_initialvalue=False) + self.cb_ask_str( + prompt, initialvalue=download_url, cli_show_initialvalue=False + ) else: input(prompt) def get_signal_chromedriver_version(self, electron_bin_path: str) -> Optional[str]: - if RunBin.get_bin('strings', silent=True): - output_str = RunBin.run_cmd(cmd_list=['strings', electron_bin_path], silence=True) - ss = output_str.split('\n') + if RunBin.get_bin("strings", silent=True): + status, output_str = RunBin.run_cmd( + cmd_list=["strings", electron_bin_path], silence=True + ) + if status is False: + return None + ss = output_str.split("\n") else: ss = strings(electron_bin_path) for s in ss: - if 'Chrome/' in s and ' Electron/' in s: - major_version = s.replace('Chrome/', '').split('.', 1)[0] + if "Chrome/" in s and " Electron/" in s: + major_version = s.replace("Chrome/", "").split(".", 1)[0] if major_version.isnumeric(): return major_version return None - - def get_local_chromedriver(self, chromedriver_download_dir: str) -> tuple[Optional[str], Optional[str]]: + + def get_local_chromedriver( + self, chromedriver_download_dir: Path + ) -> tuple[Optional[Path], Optional[str]]: local_chromedriver_version = None - if platform.system() == 'Windows': - chromedriver_name = 'chromedriver.exe' + if platform.system() == "Windows": + chromedriver_name = "chromedriver.exe" else: - chromedriver_name = 'chromedriver' + chromedriver_name = "chromedriver" chromedriver_path = Path(chromedriver_download_dir, chromedriver_name).resolve() if not chromedriver_path.is_file(): - chromedriver_path = shutil.which('chromedriver') # type: ignore[assignment] + chromedriver_which = shutil.which("chromedriver") + if chromedriver_which: + chromedriver_path = Path(chromedriver_which) if chromedriver_path: - output_str = RunBin.run_cmd(cmd_list=[chromedriver_path, '-v'], silence=True) - local_chromedriver_version = output_str.split(' ')[1].split('.', 1)[0] + status, output_str = RunBin.run_cmd( + cmd_list=[str(chromedriver_path), "-v"], silence=True + ) + if status is False: + local_chromedriver_version = None + local_chromedriver_version = output_str.split(" ")[1].split(".", 1)[0] else: local_chromedriver_version = None - + return chromedriver_path, local_chromedriver_version - - def download_chromedriver(self, major_version: str, chromedriver_download_dir: str = '') -> str: - if platform.system() == 'Windows': - chromedriver_platform = 'win32' - if '64' in platform.architecture()[0]: - chromedriver_platform_new = 'win64' + + def download_chromedriver( + self, major_version: str, chromedriver_download_dir: Path + ) -> Optional[Path]: + if platform.system() == "Windows": + chromedriver_platform = "win32" + if "64" in platform.architecture()[0]: + chromedriver_platform_new = "win64" else: - chromedriver_platform_new = 'win32' - elif platform.system() == 'Darwin': - if platform.processor().lower() == 'arm64': - chromedriver_platform = 'mac_arm64' - chromedriver_platform_new = 'mac-arm64' + chromedriver_platform_new = "win32" + elif platform.system() == "Darwin": + if platform.processor().lower() == "arm64": + chromedriver_platform = "mac_arm64" + chromedriver_platform_new = "mac-arm64" else: - chromedriver_platform = 'mac64' - chromedriver_platform_new = 'mac-x64' + chromedriver_platform = "mac64" + chromedriver_platform_new = "mac-x64" else: - chromedriver_platform = 'linux64' - chromedriver_platform_new = 'linux64' + chromedriver_platform = "linux64" + chromedriver_platform_new = "linux64" chromedriver_url = None - chromedriver_version_url = f'https://chromedriver.storage.googleapis.com/LATEST_RELEASE_{major_version}' + chromedriver_version_url = f"https://chromedriver.storage.googleapis.com/LATEST_RELEASE_{major_version}" r = requests.get(chromedriver_version_url) if r.ok: new_chrome = False chromedriver_version = r.text - chromedriver_url = f'https://chromedriver.storage.googleapis.com/{chromedriver_version}/chromedriver_{chromedriver_platform}.zip' + chromedriver_url = f"https://chromedriver.storage.googleapis.com/{chromedriver_version}/chromedriver_{chromedriver_platform}.zip" else: new_chrome = True - r = requests.get('https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone-with-downloads.json') + r = requests.get( + "https://googlechromelabs.github.io/chrome-for-testing/latest-versions-per-milestone-with-downloads.json" + ) versions_dict = json.loads(r.text) - chromedriver_list = versions_dict \ - .get('milestones', {}) \ - .get(major_version, {}) \ - .get('downloads', {}) \ - .get('chromedriver', {}) - + chromedriver_list = ( + versions_dict.get("milestones", {}) + .get(major_version, {}) + .get("downloads", {}) + .get("chromedriver", {}) + ) + chromedriver_url = None for i in chromedriver_list: - if i.get('platform') == chromedriver_platform_new: - chromedriver_url = i.get('url') - + if i.get("platform") == chromedriver_platform_new: + chromedriver_url = i.get("url") + if not chromedriver_url: - return '' + return None - if platform.system() == 'Windows': - chromedriver_name = 'chromedriver.exe' + if platform.system() == "Windows": + chromedriver_name = "chromedriver.exe" else: - chromedriver_name = 'chromedriver' + chromedriver_name = "chromedriver" if new_chrome: - chromedriver_zip_path = f'chromedriver-{chromedriver_platform_new}/{chromedriver_name}' + chromedriver_zip_path = ( + f"chromedriver-{chromedriver_platform_new}/{chromedriver_name}" + ) else: chromedriver_zip_path = chromedriver_name - + chromedriver_path = Path(chromedriver_download_dir, chromedriver_name).resolve() with io.BytesIO() as f: - f.write(requests.get(chromedriver_url).content) # type: ignore[arg-type] - with zipfile.ZipFile(f, 'r') as z, open(chromedriver_path, 'wb+') as g: + f.write(requests.get(chromedriver_url).content) # type: ignore + with zipfile.ZipFile(f, "r") as z, open(chromedriver_path, "wb+") as g: g.write(z.read(chromedriver_zip_path)) - if platform.system() != 'Windows': + if platform.system() != "Windows": st = os.stat(chromedriver_path) os.chmod(chromedriver_path, st.st_mode | stat.S_IEXEC) - + return chromedriver_path - + def killall_signal(self): - if platform.system() == 'Windows': + if platform.system() == "Windows": os.system('taskkill /F /im "Signal.exe"') os.system('taskkill /F /im "Signal Beta.exe"') else: - RunBin.run_cmd(cmd_list=['killall', 'signal-desktop'], silence=True) - RunBin.run_cmd(cmd_list=['killall', 'signal-desktop-beta'], silence=True) - - def launch_signal(self, signal_bin_path: str, signal_user_data_dir: str, chromedriver_path: str): + RunBin.run_cmd(cmd_list=["killall", "signal-desktop"], silence=True) + RunBin.run_cmd(cmd_list=["killall", "signal-desktop-beta"], silence=True) + + def launch_signal( + self, signal_bin_path: str, signal_user_data_dir: str, chromedriver_path: str + ): options = webdriver.ChromeOptions() options.binary_location = signal_bin_path - options.add_argument(f"user-data-dir={signal_user_data_dir}") - options.add_argument('no-sandbox') + options.add_argument(f"user-data-dir={signal_user_data_dir}") # type: ignore + options.add_argument("no-sandbox") # type: ignore service = Service(executable_path=chromedriver_path) self.driver = webdriver.Chrome(options=options, service=service) @@ -182,87 +211,136 @@ def get_cred(self) -> tuple[Optional[str], Optional[str]]: return None, None # https://stackoverflow.com/a/73456344 - uuid, password = None, None + uuid: Optional[str] = None + password: Optional[str] = None try: - if self.signal_bin_version == 'prod': - uuid = self.driver.execute_script('return window.reduxStore.getState().items.uuid_id') - password = self.driver.execute_script('return window.reduxStore.getState().items.password') + if self.signal_bin_version == "prod": + uuid = self.driver.execute_script( # type: ignore + "return window.reduxStore.getState().items.uuid_id" + ) + password = self.driver.execute_script( # type: ignore + "return window.reduxStore.getState().items.password" + ) else: - uuid = self.driver.execute_script('return window.SignalDebug.getReduxState().items.uuid_id') - password = self.driver.execute_script('return window.SignalDebug.getReduxState().items.password') + uuid = self.driver.execute_script( # type: ignore + "return window.SignalDebug.getReduxState().items.uuid_id" + ) + password = self.driver.execute_script( # type: ignore + "return window.SignalDebug.getReduxState().items.password" + ) except JavascriptException: pass + assert isinstance(uuid, str) + assert isinstance(password, str) return uuid, password def close(self): - self.cb_msg('Closing Signal Desktop') + self.cb_msg("Closing Signal Desktop") self.driver.quit() def launch_signal_desktop(self) -> bool: - if platform.system() == 'Windows': - signal_bin_path_prod = os.path.expandvars("%localappdata%/Programs/signal-desktop/Signal.exe") - signal_bin_path_beta = os.path.expandvars("%localappdata%/Programs/signal-desktop-beta/Signal Beta.exe") - signal_user_data_dir_prod = os.path.abspath(os.path.expandvars('%appdata%/Signal')) - signal_user_data_dir_beta = os.path.abspath(os.path.expandvars('%appdata%/Signal Beta')) + if platform.system() == "Windows": + signal_bin_path_prod = os.path.expandvars( + "%localappdata%/Programs/signal-desktop/Signal.exe" + ) + signal_bin_path_beta = os.path.expandvars( + "%localappdata%/Programs/signal-desktop-beta/Signal Beta.exe" + ) + signal_user_data_dir_prod = os.path.abspath( + os.path.expandvars("%appdata%/Signal") + ) + signal_user_data_dir_beta = os.path.abspath( + os.path.expandvars("%appdata%/Signal Beta") + ) electron_bin_path_prod = signal_bin_path_prod electron_bin_path_beta = signal_bin_path_beta - elif platform.system() == 'Darwin': + elif platform.system() == "Darwin": signal_bin_path_prod = "/Applications/Signal.app/Contents/MacOS/Signal" - signal_bin_path_beta = "/Applications/Signal Beta.app/Contents/MacOS/Signal Beta" - signal_user_data_dir_prod = os.path.expanduser('~/Library/Application Support/Signal') - signal_user_data_dir_beta = os.path.expanduser('~/Library/Application Support/Signal Beta') - electron_bin_path_prod = '/Applications/Signal.app/Contents/Frameworks/Electron Framework.framework/Electron Framework' - electron_bin_path_beta = '/Applications/Signal Beta.app/Contents/Frameworks/Electron Framework.framework/Electron Framework' + signal_bin_path_beta = ( + "/Applications/Signal Beta.app/Contents/MacOS/Signal Beta" + ) + signal_user_data_dir_prod = os.path.expanduser( + "~/Library/Application Support/Signal" + ) + signal_user_data_dir_beta = os.path.expanduser( + "~/Library/Application Support/Signal Beta" + ) + electron_bin_path_prod = "/Applications/Signal.app/Contents/Frameworks/Electron Framework.framework/Electron Framework" + electron_bin_path_beta = "/Applications/Signal Beta.app/Contents/Frameworks/Electron Framework.framework/Electron Framework" else: signal_bin_path_prod = "signal-desktop" signal_bin_path_beta = "signal-desktop-beta" - signal_user_data_dir_prod = os.path.expanduser('~/.config/Signal') - signal_user_data_dir_beta = os.path.expanduser('~/.config/Signal Beta') + signal_user_data_dir_prod = os.path.expanduser("~/.config/Signal") + signal_user_data_dir_beta = os.path.expanduser("~/.config/Signal Beta") electron_bin_path_prod = signal_bin_path_prod electron_bin_path_beta = signal_bin_path_beta - if self.signal_bin_version == 'prod': + if self.signal_bin_version == "prod": signal_bin_path = signal_bin_path_prod signal_user_data_dir = signal_user_data_dir_prod electron_bin_path = electron_bin_path_prod - signal_download_url = 'https://signal.org/en/download/' + signal_download_url = "https://signal.org/en/download/" else: signal_bin_path = signal_bin_path_beta signal_user_data_dir = signal_user_data_dir_beta electron_bin_path = electron_bin_path_beta - signal_download_url = 'https://support.signal.org/hc/en-us/articles/360007318471-Signal-Beta' + signal_download_url = ( + "https://support.signal.org/hc/en-us/articles/360007318471-Signal-Beta" + ) if not (Path(signal_bin_path).is_file() or shutil.which(signal_bin_path)): success = self.download_signal_desktop(signal_download_url, signal_bin_path) - + if not success: return False - - electron_bin_path = shutil.which(electron_bin_path) if not Path(electron_bin_path).is_file() else electron_bin_path - - signal_bin_path = signal_bin_path if not shutil.which(signal_bin_path) else shutil.which(signal_bin_path) - + + electron_bin_path = ( + shutil.which(electron_bin_path) + if not Path(electron_bin_path).is_file() + else electron_bin_path + ) + if not electron_bin_path: + self.cb_msg("Cannot find Electron Framework inside Signal installation") + return False + + signal_bin_path = ( + signal_bin_path + if not shutil.which(signal_bin_path) + else shutil.which(signal_bin_path) + ) + if not signal_bin_path: + self.cb_msg("Cannot find Signal installation") + return False + major_version = self.get_signal_chromedriver_version(electron_bin_path) if major_version: - self.cb_msg(f'Signal Desktop is using chrome version {major_version}') + self.cb_msg(f"Signal Desktop is using chrome version {major_version}") else: - self.cb_msg('Unable to determine Signal Desktop chrome version') + self.cb_msg("Unable to determine Signal Desktop chrome version") return False - - chromedriver_path, local_chromedriver_version = self.get_local_chromedriver(chromedriver_download_dir=self.chromedriver_download_dir) + + chromedriver_path, local_chromedriver_version = self.get_local_chromedriver( + chromedriver_download_dir=self.chromedriver_download_dir + ) if chromedriver_path and local_chromedriver_version == major_version: - self.cb_msg(f'Found chromedriver version {local_chromedriver_version}, skip downloading') + self.cb_msg( + f"Found chromedriver version {local_chromedriver_version}, skip downloading" + ) else: - chromedriver_path = self.download_chromedriver(major_version, chromedriver_download_dir=self.chromedriver_download_dir) - if chromedriver_path == '': - self.cb_msg('Unable to download suitable chromedriver') + chromedriver_path = self.download_chromedriver( + major_version, chromedriver_download_dir=self.chromedriver_download_dir + ) + if not chromedriver_path: + self.cb_msg("Unable to download suitable chromedriver") return False - self.cb_msg('Killing all Signal Desktop processes') + self.cb_msg("Killing all Signal Desktop processes") self.killall_signal() - self.cb_msg('Starting Signal Desktop with Selenium') - self.launch_signal(signal_bin_path, signal_user_data_dir, chromedriver_path) + self.cb_msg("Starting Signal Desktop with Selenium") + self.launch_signal( + signal_bin_path, signal_user_data_dir, str(chromedriver_path) + ) - return True \ No newline at end of file + return True diff --git a/src/sticker_convert/utils/callback.py b/src/sticker_convert/utils/callback.py index fc4451e..9310496 100644 --- a/src/sticker_convert/utils/callback.py +++ b/src/sticker_convert/utils/callback.py @@ -1,23 +1,32 @@ #!/usr/bin/env python3 -from multiprocessing import Event -from typing import Optional, Callable +from abc import ABC import copy +from multiprocessing import Event +from typing import Callable, Optional, Union, Any from tqdm import tqdm -class CallbackReturn: +class DummyCallbackReturn(ABC): + def set_response(self, response: Any): + pass + + def get_response(self): + pass + + +class CallbackReturn(DummyCallbackReturn): def __init__(self): self.response_event = Event() self.response = None - def set_response(self, response): + def set_response(self, response: Any): self.response = response self.response_event.set() - + def get_response(self): self.response_event.wait() - + response = copy.deepcopy(self.response) self.response = None @@ -26,20 +35,30 @@ def get_response(self): return response -class Callback: - def __init__( - self, - msg: Optional[Callable] = None, - bar: Optional[Callable] = None, - msg_block: Optional[Callable] = None, - ask_bool: Optional[Callable] = None, - ask_str: Optional[Callable] = None, - silent=False, - no_confirm=False - ): +class DummyCallback(ABC): + def put( + self, + i: Union[ + tuple[Optional[str], Optional[tuple[Any, ...]], Optional[dict[str, Any]]], + str, + ], + ) -> Union[str, bool, None]: + pass + +class Callback(DummyCallback): + def __init__( + self, + msg: Optional[Callable[..., None]] = None, + bar: Optional[Callable[..., None]] = None, + msg_block: Optional[Callable[..., None]] = None, + ask_bool: Optional[Callable[..., bool]] = None, + ask_str: Optional[Callable[..., str]] = None, + silent: bool = False, + no_confirm: bool = False, + ): self.progress_bar = None - + if msg: self.msg = msg else: @@ -49,17 +68,17 @@ def __init__( self.bar = bar else: self.bar = self.cb_bar - + if msg_block: self.msg_block = msg_block else: self.msg_block = self.cb_msg_block - + if ask_bool: self.ask_bool = ask_bool else: self.ask_bool = self.cb_ask_bool - + if ask_str: self.ask_str = ask_str else: @@ -67,13 +86,13 @@ def __init__( self.silent = silent self.no_confirm = no_confirm - - def cb_msg(self, *args, **kwargs): + + def cb_msg(self, *args: Any, **kwargs: Any): if self.silent: return - - msg = kwargs.get('msg') - file = kwargs.get('file') + + msg = kwargs.get("msg") + file = kwargs.get("file") if not msg and len(args) == 1: msg = str(args[0]) @@ -86,75 +105,105 @@ def cb_msg(self, *args, **kwargs): else: print(msg) - def cb_bar(self, set_progress_mode: Optional[str] = None, steps: Optional[int] = None, update_bar: bool = False): + def cb_bar( + self, + set_progress_mode: Optional[str] = None, + steps: Optional[int] = None, + update_bar: bool = False, + ): if self.silent: return - - if update_bar: - self.progress_bar.update() - elif set_progress_mode == 'determinate': - self.progress_bar = tqdm(total=steps) - elif set_progress_mode == 'indeterminate': - if self.progress_bar: + + if self.progress_bar: + if update_bar: + self.progress_bar.update() + elif set_progress_mode == "indeterminate": self.progress_bar.close() self.progress_bar = None - elif set_progress_mode == 'clear': - if self.progress_bar: + elif set_progress_mode == "clear": self.progress_bar.reset() + elif set_progress_mode == "determinate": + self.progress_bar = tqdm(total=steps) - def cb_msg_block(self, *args): + def cb_msg_block(self, *args: Any): if self.silent: return if len(args) > 0: - msg = ' '.join(str(i) for i in args) + msg = " ".join(str(i) for i in args) self.msg(msg) if not self.no_confirm: - input('Press Enter to continue...') + input("Press Enter to continue...") - def cb_ask_bool(self, *args, **kwargs): + def cb_ask_bool(self, *args: Any, **kwargs: Any) -> bool: question = args[0] self.msg(question) if self.no_confirm: - self.msg('"--no-confirm" flag is set. Continue with this run without asking questions') + self.msg( + '"--no-confirm" flag is set. Continue with this run without asking questions' + ) return True else: - self.msg('If you do not want to get asked by this question, add "--no-confirm" flag') + self.msg( + 'If you do not want to get asked by this question, add "--no-confirm" flag' + ) self.msg() - result = input('Continue? [y/N] > ') - if result.lower() != 'y': - self.msg('Cancelling this run') + result = input("Continue? [y/N] > ") + if result.lower() != "y": + self.msg("Cancelling this run") return False else: return True - - def cb_ask_str(self, msg: Optional[str] = None, initialvalue: Optional[str] = None, cli_show_initialvalue: bool = True) -> str: + + def cb_ask_str( + self, + msg: Optional[str] = None, + initialvalue: Optional[str] = None, + cli_show_initialvalue: bool = True, + ) -> str: self.msg(msg) - hint = '' + hint = "" if cli_show_initialvalue and initialvalue: - hint = f' [Default: {initialvalue}]' + hint = f" [Default: {initialvalue}]" - response = input(f'Enter your response and press enter{hint} > ') + response = input(f"Enter your response and press enter{hint} > ") if initialvalue and not response: response = initialvalue - + return response - def put(self, action: Optional[str], args: Optional[tuple] = None, kwargs: Optional[dict] = None): - if args == None: + def put( + self, + i: Union[ + tuple[Optional[str], Optional[tuple[Any, ...]], Optional[dict[str, Any]]], + str, + ], + ) -> Union[str, bool, None]: + if isinstance(i, tuple): + action = i[0] + if len(i) >= 2: + args: tuple[str, ...] = i[1] if i[1] else tuple() + else: + args = tuple() + if len(i) >= 3: + kwargs: dict[str, Any] = i[2] if i[2] else dict() + else: + kwargs = dict() + else: + action = i args = tuple() - if kwargs == None: kwargs = dict() + # Fake implementation for Queue.put() - if action == None: + if action is None: return elif action == "msg": self.msg(*args, **kwargs) elif action == "bar": - self.bar(*args, **kwargs) + self.bar(**kwargs) elif action == "update_bar": self.bar(update_bar=True) elif action == "msg_block": @@ -162,6 +211,6 @@ def put(self, action: Optional[str], args: Optional[tuple] = None, kwargs: Optio elif action == "ask_bool": return self.ask_bool(*args, **kwargs) elif action == "ask_str": - return self.ask_str(*args, **kwargs) + return self.ask_str(**kwargs) else: - self.msg(action) \ No newline at end of file + self.msg(action) diff --git a/src/sticker_convert/utils/files/cache_store.py b/src/sticker_convert/utils/files/cache_store.py index 7b02f87..4097ff6 100755 --- a/src/sticker_convert/utils/files/cache_store.py +++ b/src/sticker_convert/utils/files/cache_store.py @@ -8,27 +8,35 @@ if platform.system() == "Linux": import memory_tempfile # type: ignore - tempfile = memory_tempfile.MemoryTempfile(fallback=True) + tempfile = memory_tempfile.MemoryTempfile(fallback=True) # type: ignore else: import tempfile -import contextlib -from typing import Optional +from contextlib import contextmanager +from typing import ContextManager, Optional, Union -@contextlib.contextmanager -def debug_cache_dir(path: str): - path_random = Path(path, str(uuid4())) - os.mkdir(path_random) - try: - yield path_random - finally: - shutil.rmtree(path_random) +from tempfile import TemporaryDirectory + + +def debug_cache_dir(path: str) -> ContextManager[Path]: + @contextmanager + def generator(): + path_random = Path(path, str(uuid4())) + os.mkdir(path_random) + try: + yield path_random + finally: + shutil.rmtree(path_random) + + return generator() class CacheStore: @staticmethod - def get_cache_store(path: Optional[str] = None) -> Path: + def get_cache_store( + path: Optional[str] = None, + ) -> Union[ContextManager[Path], TemporaryDirectory[str]]: if path: return debug_cache_dir(path) else: - return Path(tempfile.TemporaryDirectory()) + return tempfile.TemporaryDirectory() # type: ignore diff --git a/src/sticker_convert/utils/files/json_manager.py b/src/sticker_convert/utils/files/json_manager.py index 5040364..0ccfbf3 100755 --- a/src/sticker_convert/utils/files/json_manager.py +++ b/src/sticker_convert/utils/files/json_manager.py @@ -1,20 +1,20 @@ #!/usr/bin/env python3 import json from pathlib import Path -from typing import Optional +from typing import Any class JsonManager: @staticmethod - def load_json(path: Path) -> Optional[dict]: + def load_json(path: Path) -> dict[Any, Any]: if not path.is_file(): - return None + raise RuntimeError(f"{path} cannot be found") else: with open(path, encoding="utf-8") as f: data = json.load(f) return data @staticmethod - def save_json(path: Path, data: dict): + def save_json(path: Path, data: dict[Any, Any]): with open(path, "w+", encoding="utf-8") as f: json.dump(data, f, indent=4) diff --git a/src/sticker_convert/utils/files/metadata_handler.py b/src/sticker_convert/utils/files/metadata_handler.py index 936f9ff..78df24c 100755 --- a/src/sticker_convert/utils/files/metadata_handler.py +++ b/src/sticker_convert/utils/files/metadata_handler.py @@ -5,9 +5,9 @@ from pathlib import Path from typing import Optional -from sticker_convert.definitions import ROOT_DIR # type: ignore -from sticker_convert.utils.files.json_manager import JsonManager # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore +from sticker_convert.definitions import ROOT_DIR +from sticker_convert.utils.files.json_manager import JsonManager +from sticker_convert.utils.media.codec_info import CodecInfo def check_if_xcodeproj(path: Path) -> bool: @@ -21,49 +21,66 @@ def check_if_xcodeproj(path: Path) -> bool: class MetadataHandler: @staticmethod - def get_files_related_to_sticker_convert(dir: Path, include_archive: bool = True) -> list[Path]: - from sticker_convert.uploaders.xcode_imessage import XcodeImessageIconset # type: ignore + def get_files_related_to_sticker_convert( + dir: Path, include_archive: bool = True + ) -> list[Path]: + from sticker_convert.uploaders.xcode_imessage import XcodeImessageIconset xcode_iconset = XcodeImessageIconset().iconset related_extensions = ( - ".png", ".apng", ".jpg", ".jpeg", ".gif", - ".tgs", ".lottie", ".json", - ".mp4", ".mkv", ".mov", ".webm", ".webp", ".avi", - ".m4a", ".wastickers" + ".png", + ".apng", + ".jpg", + ".jpeg", + ".gif", + ".tgs", + ".lottie", + ".json", + ".mp4", + ".mkv", + ".mov", + ".webm", + ".webp", + ".avi", + ".m4a", + ".wastickers", ) related_name = ( - "title.txt", "author.txt", "emoji.txt", - 'export-result.txt', ".DS_Store", "._.DS_Store" + "title.txt", + "author.txt", + "emoji.txt", + "export-result.txt", + ".DS_Store", + "._.DS_Store", ) files = [ i for i in sorted(dir.iterdir()) - if i.stem in related_name or - i.name in xcode_iconset or - i.suffix in related_extensions or - (include_archive and i.name.startswith('archive_')) or - check_if_xcodeproj(i) + if i.stem in related_name + or i.name in xcode_iconset + or i.suffix in related_extensions + or (include_archive and i.name.startswith("archive_")) + or check_if_xcodeproj(i) ] return files - @staticmethod def get_stickers_present(dir: Path) -> list[Path]: - from sticker_convert.uploaders.xcode_imessage import XcodeImessageIconset # type: ignore + from sticker_convert.uploaders.xcode_imessage import XcodeImessageIconset - blacklist_prefix = ('cover',) + blacklist_prefix = ("cover",) blacklist_suffix = (".txt", ".m4a", ".wastickers", ".DS_Store", "._.DS_Store") xcode_iconset = XcodeImessageIconset().iconset - + stickers_present = [ i for i in sorted(dir.iterdir()) - if Path(dir, i).is_file() and - not i.name.startswith(blacklist_prefix) and - not i.suffix in blacklist_suffix and - not i.name in xcode_iconset + if Path(dir, i.name).is_file() + and not i.name.startswith(blacklist_prefix) + and i.suffix not in blacklist_suffix + and i.name not in xcode_iconset ] return stickers_present @@ -73,7 +90,7 @@ def get_cover(dir: Path) -> Optional[Path]: stickers_present = sorted(dir.iterdir()) for i in stickers_present: if Path(i).stem == "cover": - return Path(dir, i) + return Path(dir, i.name) return None @@ -109,17 +126,17 @@ def set_metadata( emoji_dict: Optional[dict[str, str]] = None, ): title_path = Path(dir, "title.txt") - if title != None: + if title is not None: with open(title_path, "w+", encoding="utf-8") as f: - f.write(title) # type: ignore[arg-type] + f.write(title) author_path = Path(dir, "author.txt") - if author != None: + if author is not None: with open(author_path, "w+", encoding="utf-8") as f: - f.write(author) # type: ignore[arg-type] + f.write(author) emoji_path = Path(dir, "emoji.txt") - if emoji_dict != None: + if emoji_dict is not None: with open(emoji_path, "w+", encoding="utf-8") as f: json.dump(emoji_dict, f, indent=4, ensure_ascii=False) @@ -134,6 +151,7 @@ def check_metadata_provided( metadata = 'title' or 'author' """ input_presets = JsonManager.load_json(ROOT_DIR / "resources/input.json") + assert input_presets if input_option == "local": metadata_file_path = Path(input_dir, f"{metadata}.txt") @@ -152,6 +170,7 @@ def check_metadata_provided( def check_metadata_required(output_option: str, metadata: str) -> bool: # metadata = 'title' or 'author' output_presets = JsonManager.load_json(ROOT_DIR / "resources/output.json") + assert output_presets return output_presets[output_option]["metadata_requirements"][metadata] @staticmethod @@ -164,9 +183,10 @@ def generate_emoji_file(dir: Path, default_emoji: str = ""): emoji_dict_new = {} for file in sorted(dir.iterdir()): - if not Path(dir, file).is_file() and CodecInfo.get_file_ext( - file - ) in (".txt", ".m4a"): + if not Path(dir, file).is_file() and CodecInfo.get_file_ext(file) in ( + ".txt", + ".m4a", + ): continue file_name = Path(file).stem if emoji_dict and file_name in emoji_dict: @@ -185,14 +205,14 @@ def split_sticker_packs( file_per_anim_pack: Optional[int] = None, file_per_image_pack: Optional[int] = None, separate_image_anim: bool = True, - ) -> dict: + ) -> dict[str, list[Path]]: # {pack_1: [sticker1_path, sticker2_path]} - packs = {} + packs: dict[str, list[Path]] = {} - if file_per_pack == None: + if file_per_pack is None: file_per_pack = ( file_per_anim_pack - if file_per_anim_pack != None + if file_per_anim_pack is not None else file_per_image_pack ) else: @@ -203,9 +223,9 @@ def split_sticker_packs( processed = 0 - if separate_image_anim == True: - image_stickers = [] - anim_stickers = [] + if separate_image_anim is True: + image_stickers: list[Path] = [] + anim_stickers: list[Path] = [] image_pack_count = 0 anim_pack_count = 0 @@ -244,7 +264,7 @@ def split_sticker_packs( image_pack_count += 1 else: - stickers = [] + stickers: list[Path] = [] pack_count = 0 for processed, file in enumerate(stickers_present): diff --git a/src/sticker_convert/utils/files/run_bin.py b/src/sticker_convert/utils/files/run_bin.py index 7612be4..2c5531a 100755 --- a/src/sticker_convert/utils/files/run_bin.py +++ b/src/sticker_convert/utils/files/run_bin.py @@ -3,14 +3,14 @@ import shutil import subprocess from pathlib import Path -from typing import AnyStr, Union +from typing import Union, Callable, Any class RunBin: @staticmethod def get_bin( - bin: str, silent: bool = False, cb_msg=print - ) -> Union[str, AnyStr, None]: + bin: str, silent: bool = False, cb_msg: Callable[..., Any] = print + ) -> Union[str, None]: if Path(bin).is_file(): return bin @@ -18,27 +18,27 @@ def get_bin( bin = bin + ".exe" which_result = shutil.which(bin) - if which_result != None: - return Path(which_result).resolve() # type: ignore[type-var] - elif silent == False: + if which_result is not None: + return str(Path(which_result).resolve()) + elif silent is False: cb_msg(f"Warning: Cannot find binary file {bin}") return None @staticmethod def run_cmd( - cmd_list: list[str], silence: bool = False, cb_msg=print - ) -> Union[bool, str]: - bin_path = RunBin.get_bin(cmd_list[0]) # type: ignore[assignment] + cmd_list: list[str], silence: bool = False, cb_msg: Callable[..., Any] = print + ) -> tuple[bool, str]: + bin_path = RunBin.get_bin(cmd_list[0]) if bin_path: cmd_list[0] = bin_path else: - if silence == False: + if silence is False: cb_msg( f"Error while executing {' '.join(cmd_list)} : Command not found" ) - return False + return False, "" # sp = subprocess.Popen(cmd_list, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) sp = subprocess.run( @@ -51,8 +51,8 @@ def run_cmd( output_str = sp.stdout.decode() error_str = sp.stderr.decode() - if silence == False and error_str != "": + if silence is False and error_str != "": cb_msg(f"Error while executing {' '.join(cmd_list)} : {error_str}") - return False + return False, "" - return output_str + return True, output_str diff --git a/src/sticker_convert/utils/files/sanitize_filename.py b/src/sticker_convert/utils/files/sanitize_filename.py index 7773208..431d475 100755 --- a/src/sticker_convert/utils/files/sanitize_filename.py +++ b/src/sticker_convert/utils/files/sanitize_filename.py @@ -15,9 +15,28 @@ def sanitize_filename(filename: str) -> str: """ blacklist = ["\\", "/", ":", "*", "?", '"', "<", ">", "|", "\0"] reserved = [ - "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", - "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", - "LPT6", "LPT7", "LPT8", "LPT9", + "CON", + "PRN", + "AUX", + "NUL", + "COM1", + "COM2", + "COM3", + "COM4", + "COM5", + "COM6", + "COM7", + "COM8", + "COM9", + "LPT1", + "LPT2", + "LPT3", + "LPT4", + "LPT5", + "LPT6", + "LPT7", + "LPT8", + "LPT9", ] # Reserved words on Windows filename = "".join(c if c not in blacklist else "_" for c in filename) # Remove all charcters below code point 32 @@ -49,4 +68,4 @@ def sanitize_filename(filename: str) -> str: filename = filename.rstrip(". ") if len(filename) == 0: filename = "__" - return filename \ No newline at end of file + return filename diff --git a/src/sticker_convert/utils/media/apple_png_normalize.py b/src/sticker_convert/utils/media/apple_png_normalize.py index 89e7c1d..8ee64e8 100755 --- a/src/sticker_convert/utils/media/apple_png_normalize.py +++ b/src/sticker_convert/utils/media/apple_png_normalize.py @@ -23,15 +23,19 @@ def normalize(old_png: bytes) -> bytes: found_cgbi = False # For each chunk in the PNG file + width = None + height = None while chunk_pos < len(old_png): # Reading chunk chunk_length = old_png[chunk_pos : chunk_pos + 4] chunk_length = struct.unpack(">L", chunk_length)[0] chunk_type = old_png[chunk_pos + 4 : chunk_pos + 8] - chunk_data = old_png[chunk_pos + 8 : chunk_pos + 8 + chunk_length] # type: ignore[operator] - chunk_crc = old_png[chunk_pos + chunk_length + 8 : chunk_pos + chunk_length + 12] # type: ignore[operator] + chunk_data = old_png[chunk_pos + 8 : chunk_pos + 8 + chunk_length] + chunk_crc = old_png[ + chunk_pos + chunk_length + 8 : chunk_pos + chunk_length + 12 + ] chunk_crc = struct.unpack(">L", chunk_crc)[0] - chunk_pos += chunk_length + 12 # type: ignore[operator] + chunk_pos += chunk_length + 12 # Parsing the header chunk if chunk_type == b"IHDR": @@ -49,13 +53,15 @@ def normalize(old_png: bytes) -> bytes: if not found_cgbi: return old_png + assert width + assert height bufSize = width * height * 4 + height chunk_data = zlib.decompress(chunk_d, -8, bufSize) # Swapping red & blue bytes for each pixel chunk_data = bytearray(chunk_data) offset = 1 - for y in range(height): + for _ in range(height): for x in range(width): chunk_data[offset + 4 * x], chunk_data[offset + 4 * x + 2] = ( chunk_data[offset + 4 * x + 2], @@ -66,17 +72,17 @@ def normalize(old_png: bytes) -> bytes: # Compressing the image chunk # chunk_data = newdata chunk_data = zlib.compress(chunk_data) - chunk_length = len(chunk_data) # type: ignore[assignment] - chunk_crc = zlib.crc32(b"IDAT") # type: ignore[assignment] - chunk_crc = zlib.crc32(chunk_data, chunk_crc) # type: ignore[assignment, arg-type] - chunk_crc = (chunk_crc + 0x100000000) % 0x100000000 # type: ignore[operator] + chunk_length = len(chunk_data) + chunk_crc = zlib.crc32(b"IDAT") + chunk_crc = zlib.crc32(chunk_data, chunk_crc) + chunk_crc = (chunk_crc + 0x100000000) % 0x100000000 new_png += struct.pack(">L", chunk_length) new_png += b"IDAT" new_png += chunk_data new_png += struct.pack(">L", chunk_crc) - chunk_crc = zlib.crc32(chunk_type) # type: ignore[assignment] + chunk_crc = zlib.crc32(chunk_type) new_png += struct.pack(">L", 0) new_png += b"IEND" new_png += struct.pack(">L", chunk_crc) @@ -88,7 +94,7 @@ def normalize(old_png: bytes) -> bytes: else: new_png += struct.pack(">L", chunk_length) new_png += chunk_type - if chunk_length > 0: # type: ignore[operator] + if chunk_length > 0: new_png += chunk_data new_png += struct.pack(">L", chunk_crc) diff --git a/src/sticker_convert/utils/media/codec_info.py b/src/sticker_convert/utils/media/codec_info.py index 1b5aeb1..97f0549 100755 --- a/src/sticker_convert/utils/media/codec_info.py +++ b/src/sticker_convert/utils/media/codec_info.py @@ -1,25 +1,34 @@ #!/usr/bin/env python3 from __future__ import annotations +import io import mmap from decimal import ROUND_HALF_UP, Decimal from pathlib import Path -from typing import Optional +from typing import Optional, Union from PIL import Image, UnidentifiedImageError class CodecInfo: - def __init__(self, file: Path): - self.file_ext = CodecInfo.get_file_ext(file) - self.fps, self.frames, self.duration = CodecInfo.get_file_fps_frames_duration(file) + def __init__(self, file: Union[Path, io.BytesIO], file_ext: Optional[str] = None): + if not file_ext and isinstance(file, Path): + self.file_ext = CodecInfo.get_file_ext(file) + else: + self.file_ext = file_ext + self.fps, self.frames, self.duration = CodecInfo.get_file_fps_frames_duration( + file + ) self.codec = CodecInfo.get_file_codec(file) self.res = CodecInfo.get_file_res(file) self.is_animated = True if self.fps > 1 else False @staticmethod - def get_file_fps_frames_duration(file: Path) -> tuple[float, int, int]: - file_ext = CodecInfo.get_file_ext(file) + def get_file_fps_frames_duration( + file: Union[Path, io.BytesIO], file_ext: Optional[str] = None + ) -> tuple[float, int, int]: + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) if file_ext == ".tgs": fps, frames = CodecInfo._get_file_fps_frames_tgs(file) @@ -39,12 +48,15 @@ def get_file_fps_frames_duration(file: Path) -> tuple[float, int, int]: fps = frames / duration * 1000 else: fps = 0 - + return fps, frames, duration @staticmethod - def get_file_fps(file: Path) -> float: - file_ext = CodecInfo.get_file_ext(file) + def get_file_fps( + file: Union[Path, io.BytesIO], file_ext: Optional[str] = None + ) -> float: + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) if file_ext == ".tgs": return CodecInfo._get_file_fps_tgs(file) @@ -53,35 +65,49 @@ def get_file_fps(file: Path) -> float: elif file_ext in (".gif", ".apng", ".png"): frames, duration = CodecInfo._get_file_frames_duration_pillow(file) else: - frames, duration = CodecInfo._get_file_frames_duration_av(file, frames_to_iterate=10) - + frames, duration = CodecInfo._get_file_frames_duration_av( + file, frames_to_iterate=10 + ) + if duration > 0: return frames / duration * 1000 else: return 0 - + @staticmethod - def get_file_frames(file: Path, check_anim: bool = False) -> int: + def get_file_frames( + file: Union[Path, io.BytesIO], + file_ext: Optional[str] = None, + check_anim: bool = False, + ) -> int: # If check_anim is True, return value > 1 means the file is animated - file_ext = CodecInfo.get_file_ext(file) + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) if file_ext == ".tgs": return CodecInfo._get_file_frames_tgs(file) elif file_ext in (".gif", ".webp", ".png", ".apng"): - frames, _ = CodecInfo._get_file_frames_duration_pillow(file, frames_only=True) + frames, _ = CodecInfo._get_file_frames_duration_pillow( + file, frames_only=True + ) else: - if check_anim == True: + if check_anim is True: frames_to_iterate = 2 else: frames_to_iterate = None - frames, _ = CodecInfo._get_file_frames_duration_av(file, frames_only=True, frames_to_iterate=frames_to_iterate) + frames, _ = CodecInfo._get_file_frames_duration_av( + file, frames_only=True, frames_to_iterate=frames_to_iterate + ) return frames @staticmethod - def get_file_duration(file: Path) -> int: + def get_file_duration( + file: Union[Path, io.BytesIO], file_ext: Optional[str] = None + ) -> int: # Return duration in miliseconds - file_ext = CodecInfo.get_file_ext(file) + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) if file_ext == ".tgs": fps, frames = CodecInfo._get_file_fps_frames_tgs(file) @@ -95,50 +121,58 @@ def get_file_duration(file: Path) -> int: _, duration = CodecInfo._get_file_frames_duration_pillow(file) else: _, duration = CodecInfo._get_file_frames_duration_av(file) - + return duration - + @staticmethod - def _get_file_fps_tgs(file: Path) -> int: - from rlottie_python import LottieAnimation # type: ignore + def _get_file_fps_tgs(file: Union[Path, io.BytesIO]) -> int: + from rlottie_python.rlottie_wrapper import LottieAnimation if isinstance(file, Path): - file = file.as_posix() + tgs = file.as_posix() + else: + tgs = file - with LottieAnimation.from_tgs(file) as anim: - return anim.lottie_animation_get_framerate() + with LottieAnimation.from_tgs(tgs) as anim: # type: ignore + return anim.lottie_animation_get_framerate() # type: ignore - @staticmethod - def _get_file_frames_tgs(file: Path) -> int: - from rlottie_python import LottieAnimation # type: ignore + @staticmethod + def _get_file_frames_tgs(file: Union[Path, io.BytesIO]) -> int: + from rlottie_python.rlottie_wrapper import LottieAnimation if isinstance(file, Path): - file = file.as_posix() + tgs = file.as_posix() + else: + tgs = file + + with LottieAnimation.from_tgs(tgs) as anim: # type: ignore + return anim.lottie_animation_get_totalframe() # type: ignore - with LottieAnimation.from_tgs(file) as anim: - return anim.lottie_animation_get_totalframe() - @staticmethod - def _get_file_fps_frames_tgs(file: Path) -> tuple[int, int]: - from rlottie_python import LottieAnimation # type: ignore + def _get_file_fps_frames_tgs(file: Union[Path, io.BytesIO]) -> tuple[int, int]: + from rlottie_python.rlottie_wrapper import LottieAnimation if isinstance(file, Path): - file = file.as_posix() + tgs = file.as_posix() + else: + tgs = file + + with LottieAnimation.from_tgs(tgs) as anim: # type: ignore + fps = anim.lottie_animation_get_framerate() # type: ignore + frames = anim.lottie_animation_get_totalframe() # type: ignore - with LottieAnimation.from_tgs(file) as anim: - fps = anim.lottie_animation_get_framerate() - frames = anim.lottie_animation_get_totalframe() + return fps, frames # type: ignore - return fps, frames - @staticmethod - def _get_file_frames_duration_pillow(file: Path, frames_only: bool = False) -> tuple[int, int]: + def _get_file_frames_duration_pillow( + file: Union[Path, io.BytesIO], frames_only: bool = False + ) -> tuple[int, int]: total_duration = 0 with Image.open(file) as im: if "n_frames" in im.__dir__(): frames = im.n_frames - if frames_only == True: + if frames_only is True: return frames, 1 for i in range(im.n_frames): im.seek(i) @@ -146,14 +180,16 @@ def _get_file_frames_duration_pillow(file: Path, frames_only: bool = False) -> t return frames, total_duration else: return 1, 0 - + @staticmethod - def _get_file_frames_duration_webp(file: Path) -> tuple[int, int]: + def _get_file_frames_duration_webp( + file: Union[Path, io.BytesIO], + ) -> tuple[int, int]: total_duration = 0 frames = 0 - - with open(file, "r+b") as f: - with mmap.mmap(f.fileno(), 0) as mm: + + with open(file, "r+b") as f: # type: ignore + with mmap.mmap(f.fileno(), 0) as mm: # type: ignore while True: anmf_pos = mm.find(b"ANMF") if anmf_pos == -1: @@ -165,55 +201,78 @@ def _get_file_frames_duration_webp(file: Path) -> tuple[int, int]: ) total_duration += int.from_bytes(frame_duration, "little") frames += 1 - + if frames == 0: return 1, 0 else: return frames, total_duration - + @staticmethod - def _get_file_frames_duration_av(file: Path, frames_to_iterate: Optional[int] = None, frames_only: bool = False) -> tuple[int, int]: - import av # type: ignore + def _get_file_frames_duration_av( + file: Union[Path, io.BytesIO], + frames_to_iterate: Optional[int] = None, + frames_only: bool = False, + ) -> tuple[int, int]: + import av # Getting fps and frame count from metadata is not reliable # Example: https://github.com/laggykiller/sticker-convert/issues/114 if isinstance(file, Path): - file = file.as_posix() + file_ref = file.as_posix() + else: + file_ref = file - with av.open(file) as container: + with av.open(file_ref) as container: # type: ignore stream = container.streams.video[0] - duration_metadata = int(Decimal(container.duration / 1000).quantize(0, ROUND_HALF_UP)) + if container.duration: + duration_metadata = int( + Decimal(container.duration / 1000).quantize(0, ROUND_HALF_UP) + ) + else: + duration_metadata = 0 - if frames_only == True and stream.frames > 1: + if frames_only is True and stream.frames > 1: return stream.frames, duration_metadata + frame_count = 0 last_frame = None - for frame_count, frame in enumerate(container.decode(stream)): - if frames_to_iterate != None and frame_count == frames_to_iterate: + for frame_count, frame in enumerate(container.decode(stream)): # type: ignore + if frames_to_iterate is not None and frame_count == frames_to_iterate: break - last_frame = frame - - time_base_ms = last_frame.time_base.numerator / last_frame.time_base.denominator * 1000 + last_frame = frame # type: ignore + + time_base_ms = ( # type: ignore + last_frame.time_base.numerator / last_frame.time_base.denominator * 1000 # type: ignore + ) if frame_count <= 1 or duration_metadata != 0: return frame_count, duration_metadata else: - duration_n_minus_one = last_frame.pts * time_base_ms - ms_per_frame = duration_n_minus_one / (frame_count - 1) - duration = frame_count * ms_per_frame - return frame_count, int(Decimal(duration).quantize(0, ROUND_HALF_UP)) + duration_n_minus_one = last_frame.pts * time_base_ms # type: ignore + ms_per_frame = duration_n_minus_one / (frame_count - 1) # type: ignore + duration = frame_count * ms_per_frame # type: ignore + return frame_count, int(Decimal(duration).quantize(0, ROUND_HALF_UP)) # type: ignore @staticmethod - def get_file_codec(file: Path) -> Optional[str]: - codec = None + def get_file_codec( + file: Union[Path, io.BytesIO], file_ext: Optional[str] = None + ) -> str: + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) - file_ext = CodecInfo.get_file_ext(file) + if isinstance(file, Path): + file_ref = file.as_posix() + else: + file_ref = file + + codec = None + animated = False if file_ext in (".tgs", ".lottie", ".json"): return file_ext.replace(".", "") try: with Image.open(file) as im: codec = im.format - if 'is_animated' in im.__dir__(): + if "is_animated" in im.__dir__(): animated = im.is_animated else: animated = False @@ -226,56 +285,57 @@ def get_file_codec(file: Path) -> Optional[str]: return "apng" else: return "png" - elif codec != None: + elif codec is not None: return codec.lower() - - import av # type: ignore + + import av from av.error import InvalidDataError - if isinstance(file, Path): - file = file.as_posix() - try: - with av.open(file) as container: + with av.open(file_ref) as container: # type: ignore codec = container.streams.video[0].codec_context.name except InvalidDataError: return "" - if codec == None: - return "" + return codec.lower() @staticmethod - def get_file_res(file: Path) -> tuple[int, int]: - file_ext = CodecInfo.get_file_ext(file) + def get_file_res( + file: Union[Path, io.BytesIO], file_ext: Optional[str] = None + ) -> tuple[int, int]: + if not file_ext and isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) + + if isinstance(file, Path): + file_ref = file.as_posix() + else: + file_ref = file if file_ext == ".tgs": - from rlottie_python import LottieAnimation # type: ignore + from rlottie_python.rlottie_wrapper import LottieAnimation - with LottieAnimation.from_tgs(file) as anim: - width, height = anim.lottie_animation_get_size() + with LottieAnimation.from_tgs(file_ref) as anim: # type: ignore + width, height = anim.lottie_animation_get_size() # type: ignore elif file_ext in (".webp", ".png", ".apng"): with Image.open(file) as im: width = im.width height = im.height else: - import av # type: ignore - - if isinstance(file, Path): - file = file.as_posix() + import av - with av.open(file) as container: + with av.open(file_ref) as container: # type: ignore stream = container.streams.video[0] width = stream.width height = stream.height - return width, height + return width, height # type: ignore @staticmethod def get_file_ext(file: Path) -> str: return Path(file).suffix.lower() @staticmethod - def is_anim(file: Path) -> bool: + def is_anim(file: Union[Path, io.BytesIO]) -> bool: if CodecInfo.get_file_frames(file, check_anim=True) > 1: return True else: diff --git a/src/sticker_convert/utils/media/decrypt_kakao.py b/src/sticker_convert/utils/media/decrypt_kakao.py index 7cc97bb..e7e844d 100644 --- a/src/sticker_convert/utils/media/decrypt_kakao.py +++ b/src/sticker_convert/utils/media/decrypt_kakao.py @@ -7,6 +7,7 @@ https://github.com/star-39/moe-sticker-bot """ + class DecryptKakao: @staticmethod def generate_lfsr(key: str) -> list[int]: @@ -31,7 +32,7 @@ def generate_lfsr(key: str) -> list[int]: return seq @staticmethod - def xor_byte(b: int, seq: list) -> int: + def xor_byte(b: int, seq: list[int]) -> int: flag1 = 1 flag2 = 0 result = 0 @@ -66,4 +67,3 @@ def xor_data(data: bytes) -> bytes: for i in range(0, 128): dat[i] = DecryptKakao.xor_byte(dat[i], s) return bytes(dat) - diff --git a/src/sticker_convert/utils/media/format_verify.py b/src/sticker_convert/utils/media/format_verify.py index 61261dc..716400f 100755 --- a/src/sticker_convert/utils/media/format_verify.py +++ b/src/sticker_convert/utils/media/format_verify.py @@ -1,41 +1,52 @@ #!/usr/bin/env python3 +import io import os from pathlib import Path from typing import Optional, Union -from sticker_convert.job_option import CompOption # type: ignore -from sticker_convert.utils.media.codec_info import CodecInfo # type: ignore +from sticker_convert.job_option import CompOption +from sticker_convert.utils.media.codec_info import CodecInfo class FormatVerify: @staticmethod - def check_file(file: Path, spec: CompOption) -> bool: - if FormatVerify.check_presence(file) == False: + def check_file(file: Union[Path, io.BytesIO], spec: CompOption) -> bool: + if FormatVerify.check_presence(file) is False: return False file_info = CodecInfo(file) return ( - FormatVerify.check_file_res(file, res=spec.res, square=spec.square, file_info=file_info) + FormatVerify.check_file_res( + file, res=spec.res, square=spec.square, file_info=file_info + ) and FormatVerify.check_file_fps(file, fps=spec.fps, file_info=file_info) - and FormatVerify.check_file_duration(file, duration=spec.duration, file_info=file_info) - and FormatVerify.check_file_size(file, size=spec.size_max, file_info=file_info) - and FormatVerify.check_animated(file, animated=spec.animated, file_info=file_info) + and FormatVerify.check_file_duration( + file, duration=spec.duration, file_info=file_info + ) + and FormatVerify.check_file_size( + file, size=spec.size_max, file_info=file_info + ) + and FormatVerify.check_animated( + file, animated=spec.animated, file_info=file_info + ) and FormatVerify.check_format(file, fmt=spec.format, file_info=file_info) ) @staticmethod - def check_presence(file: Path) -> bool: - return Path(file).is_file() + def check_presence(file: Union[Path, io.BytesIO]) -> bool: + if isinstance(file, Path): + return Path(file).is_file() + else: + return True @staticmethod def check_file_res( - file: Path, - res: Optional[list[list[int]]] = None, + file: Union[Path, io.BytesIO], + res: list[list[Optional[int]]], square: Optional[bool] = None, - file_info: Optional[CodecInfo] = None + file_info: Optional[CodecInfo] = None, ) -> bool: - if file_info: file_width, file_height = file_info.res else: @@ -57,10 +68,10 @@ def check_file_res( @staticmethod def check_file_fps( - file: Path,fps: Optional[list[int]], - file_info: Optional[CodecInfo] = None + file: Union[Path, io.BytesIO], + fps: list[Optional[int]], + file_info: Optional[CodecInfo] = None, ) -> bool: - if file_info: file_fps = file_info.fps else: @@ -72,14 +83,13 @@ def check_file_fps( return False return True - + @staticmethod def check_file_duration( - file: Path, - duration: Optional[list[str]] = None, - file_info: Optional[CodecInfo] = None + file: Union[Path, io.BytesIO], + duration: list[Optional[int]], + file_info: Optional[CodecInfo] = None, ) -> bool: - if file_info: file_duration = file_info.duration else: @@ -96,28 +106,31 @@ def check_file_duration( @staticmethod def check_file_size( - file: Path, - size: Optional[list[int]] = None, - file_info: Optional[CodecInfo] = None + file: Union[Path, io.BytesIO], + size: list[Optional[int]], + file_info: Optional[CodecInfo] = None, ) -> bool: - - file_size = os.path.getsize(file) + if isinstance(file, Path): + file_size = os.path.getsize(file) + else: + file_size = file.getbuffer().nbytes + if file_info: file_animated = file_info.is_animated else: file_animated = CodecInfo.is_anim(file) if ( - file_animated == True + file_animated is True and size - and size[1] != None + and size[1] is not None and file_size > size[1] ): return False if ( - file_animated == False + file_animated is False and size - and size[0] != None + and size[0] is not None and file_size > size[0] ): return False @@ -126,35 +139,36 @@ def check_file_size( @staticmethod def check_animated( - file: Path, + file: Union[Path, io.BytesIO], animated: Optional[bool] = None, - file_info: Optional[CodecInfo] = None + file_info: Optional[CodecInfo] = None, ) -> bool: - if file_info: file_animated = file_info.is_animated else: file_animated = CodecInfo.is_anim(file) - if animated != None and file_animated != animated: + if animated is not None and file_animated != animated: return False return True @staticmethod def check_format( - file: Path, - fmt: list[Union[list[str], str, None]] = None, - file_info: Optional[CodecInfo] = None + file: Union[Path, io.BytesIO], + fmt: list[list[str]], + file_info: Optional[CodecInfo] = None, ): - if file_info: file_animated = file_info.is_animated file_ext = file_info.file_ext else: file_animated = CodecInfo.is_anim(file) - file_ext = CodecInfo.get_file_ext(file) - + if isinstance(file, Path): + file_ext = CodecInfo.get_file_ext(file) + else: + file_ext = "." + CodecInfo.get_file_codec(file) + jpg_exts = (".jpg", ".jpeg") png_exts = (".png", ".apng") @@ -163,27 +177,13 @@ def check_format( else: valid_fmt = fmt[0] - if isinstance(valid_fmt, str): - if file_ext == valid_fmt: - return True - elif file_ext in jpg_exts and valid_fmt in jpg_exts: - return True - elif file_ext in png_exts and valid_fmt in png_exts: - return True - else: - return False - - elif isinstance(valid_fmt, list): - if file_ext in valid_fmt: - return True - elif file_ext in jpg_exts and (".jpg" in valid_fmt or ".jpeg" in valid_fmt): - return True - elif file_ext in png_exts and (".png" in valid_fmt or ".apng" in valid_fmt): - return True - else: - return False - - elif valid_fmt == None: - return False + if len(valid_fmt) == 0: + return True + elif file_ext in valid_fmt: + return True + elif file_ext in jpg_exts and (".jpg" in valid_fmt or ".jpeg" in valid_fmt): + return True + elif file_ext in png_exts and (".png" in valid_fmt or ".apng" in valid_fmt): + return True else: - raise TypeError(f"valid_fmt should be either str, list or None. {valid_fmt} was given") \ No newline at end of file + return False diff --git a/src/sticker_convert/version.py b/src/sticker_convert/version.py new file mode 100644 index 0000000..b1cdd58 --- /dev/null +++ b/src/sticker_convert/version.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 +__version__ = "2.5.4" diff --git a/sticker_convert_colab.ipynb b/sticker_convert_colab.ipynb index e9fff66..11efccf 100644 --- a/sticker_convert_colab.ipynb +++ b/sticker_convert_colab.ipynb @@ -326,7 +326,7 @@ "if telegram_userid != '':\n", " params.append('--telegram-userid')\n", " params.append(str(telegram_userid))\n", - "if kakao_gen_auth_token == True:\n", + "if kakao_gen_auth_token is True:\n", " params.append('--kakao-gen-auth-token')\n", "if kakao_auth_token != '':\n", " params.append('--kakao-auth-token')\n", diff --git a/tests/common.py b/tests/common.py index e6de9b1..049a092 100644 --- a/tests/common.py +++ b/tests/common.py @@ -2,15 +2,27 @@ import os import shutil import subprocess +from subprocess import CompletedProcess from pathlib import Path +from typing import Any -import pytest +import pytest # type: ignore -PYTHON_EXE = shutil.which('python3') if shutil.which('python3') else shutil.which('python') -SRC_DIR = Path(__file__).resolve().parent / '../src' -SAMPLE_DIR = Path(__file__).resolve().parent / 'samples' -COMPRESSION_JSON_PATH = SRC_DIR / 'sticker_convert/resources/compression.json' -CREDS_JSON_PATH = SRC_DIR / 'sticker_convert/creds.json' + +def get_python_path() -> str: + path = shutil.which("python3") + if not path: + shutil.which("python") + if not path: + raise RuntimeError("Cannot find python executable") + return path + + +PYTHON_EXE = get_python_path() +SRC_DIR = Path(__file__).resolve().parent / "../src" +SAMPLE_DIR = Path(__file__).resolve().parent / "samples" +COMPRESSION_JSON_PATH = SRC_DIR / "sticker_convert/resources/compression.json" +CREDS_JSON_PATH = SRC_DIR / "sticker_convert/creds.json" with open(COMPRESSION_JSON_PATH) as f: COMPRESSION_DICT = json.load(f) @@ -26,14 +38,15 @@ KAKAO_TOKEN = CREDS_JSON_DICT.get("kakao", {}).get("auth_token") LINE_COOKIES = CREDS_JSON_DICT.get("line", {}).get("cookies") else: - SIGNAL_UUID = os.environ.get("SIGNAL_UUID") - SIGNAL_PASSWORD = os.environ.get("SIGNAL_PASSWORD") - TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN") - TELEGRAM_USERID = os.environ.get("TELEGRAM_USERID") - KAKAO_TOKEN = os.environ.get("KAKAO_TOKEN") - LINE_COOKIES = os.environ.get("LINE_COOKIES") + SIGNAL_UUID = os.environ.get("SIGNAL_UUID") # type: ignore + SIGNAL_PASSWORD = os.environ.get("SIGNAL_PASSWORD") # type: ignore + TELEGRAM_TOKEN = os.environ.get("TELEGRAM_TOKEN") # type: ignore + TELEGRAM_USERID = os.environ.get("TELEGRAM_USERID") # type: ignore + KAKAO_TOKEN = os.environ.get("KAKAO_TOKEN") # type: ignore + LINE_COOKIES = os.environ.get("LINE_COOKIES") # type: ignore + -def run_cmd(cmd, **kwargs): - result = subprocess.run(cmd, **kwargs) +def run_cmd(cmd: list[str], **kwargs: Any): + result: CompletedProcess[Any] = subprocess.run(cmd, **kwargs) # type: ignore - assert result.returncode == 0 \ No newline at end of file + assert result.returncode == 0 # type: ignore diff --git a/tests/test_compression.py b/tests/test_compression.py index f91da3d..b0cd638 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -2,39 +2,50 @@ import sys from pathlib import Path -import pytest +import pytest # type: ignore -from tests.common import (COMPRESSION_DICT, PYTHON_EXE, SAMPLE_DIR, SRC_DIR, - run_cmd) +from tests.common import COMPRESSION_DICT, PYTHON_EXE, SAMPLE_DIR, SRC_DIR, run_cmd os.chdir(Path(__file__).resolve().parent) -sys.path.append('../src') +sys.path.append("../src") from sticker_convert.utils.media.codec_info import CodecInfo -SIZE_MAX_IMG = COMPRESSION_DICT.get('custom').get('size_max').get('img') -SIZE_MAX_VID = COMPRESSION_DICT.get('custom').get('size_max').get('vid') +SIZE_MAX_IMG = COMPRESSION_DICT.get("custom").get("size_max").get("img") +SIZE_MAX_VID = COMPRESSION_DICT.get("custom").get("size_max").get("vid") + def _run_sticker_convert(fmt: str, tmp_path: Path): - run_cmd([ - PYTHON_EXE, - 'sticker-convert.py', - '--input-dir', SAMPLE_DIR, - '--output-dir', tmp_path, - '--preset', 'custom', - '--duration-max', '2000', - '--steps', '6', - '--img-format', fmt, - '--vid-format', fmt - ], cwd=SRC_DIR) + run_cmd( + [ + PYTHON_EXE, + "sticker-convert.py", + "--input-dir", + str(SAMPLE_DIR), + "--output-dir", + str(tmp_path), + "--preset", + "custom", + "--duration-max", + "2000", + "--steps", + "6", + "--no-confirm", + "--img-format", + fmt, + "--vid-format", + fmt, + ], + cwd=SRC_DIR, + ) for i in SAMPLE_DIR.iterdir(): preset_dict = COMPRESSION_DICT.get("custom") - if i.startswith("static_") and preset_dict.get("fake_vid") == False: + if i.name.startswith("static_") and preset_dict.get("fake_vid") is False: size_max = preset_dict.get("size_max").get("img") else: size_max = preset_dict.get("size_max").get("vid") - + fname = Path(i).stem + fmt fpath = tmp_path / fname fps, frames, duration = CodecInfo.get_file_fps_frames_duration(fpath) @@ -43,7 +54,7 @@ def _run_sticker_convert(fmt: str, tmp_path: Path): assert fpath.is_file() assert os.path.getsize(fpath) < size_max - if i.startswith("animated_"): + if i.name.startswith("animated_"): print(f"[TEST] {fname}: {fps=} {frames=} {duration=}") duration_min = preset_dict.get("duration").get("min") duration_max = preset_dict.get("duration").get("max") @@ -53,23 +64,30 @@ def _run_sticker_convert(fmt: str, tmp_path: Path): if duration_max: assert duration <= duration_max -def test_to_static_png(tmp_path): - _run_sticker_convert('.png', tmp_path) -def test_to_static_webp(tmp_path): - _run_sticker_convert('.webp', tmp_path) +def test_to_static_png(tmp_path): # type: ignore + _run_sticker_convert(".png", tmp_path) # type: ignore + + +def test_to_static_webp(tmp_path): # type: ignore + _run_sticker_convert(".webp", tmp_path) # type: ignore + + +def test_to_animated_apng(tmp_path): # type: ignore + _run_sticker_convert(".apng", tmp_path) # type: ignore + + +def test_to_animated_gif(tmp_path): # type: ignore + _run_sticker_convert(".gif", tmp_path) # type: ignore + -def test_to_animated_apng(tmp_path): - _run_sticker_convert('.apng', tmp_path) +def test_to_animated_webm(tmp_path): # type: ignore + _run_sticker_convert(".webm", tmp_path) # type: ignore -def test_to_animated_gif(tmp_path): - _run_sticker_convert('.gif', tmp_path) -def test_to_animated_webm(tmp_path): - _run_sticker_convert('.webm', tmp_path) +def test_to_animated_webp(tmp_path): # type: ignore + _run_sticker_convert(".webp", tmp_path) # type: ignore -def test_to_animated_webp(tmp_path): - _run_sticker_convert('.webp', tmp_path) -def test_to_animated_mp4(tmp_path): - _run_sticker_convert('.mp4', tmp_path) \ No newline at end of file +def test_to_animated_mp4(tmp_path): # type: ignore + _run_sticker_convert(".mp4", tmp_path) # type: ignore diff --git a/tests/test_download.py b/tests/test_download.py index 3d977cc..fb8a2e4 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -3,374 +3,413 @@ import pytest -from tests.common import (KAKAO_TOKEN, LINE_COOKIES, PYTHON_EXE, SRC_DIR, - TELEGRAM_TOKEN, run_cmd) +from tests.common import ( + KAKAO_TOKEN, + LINE_COOKIES, + PYTHON_EXE, + SRC_DIR, + TELEGRAM_TOKEN, + run_cmd, +) TEST_DOWNLOAD = os.environ.get("TEST_DOWNLOAD") -def _run_sticker_convert( - tmp_path: Path, - source: str, - url: str, - expected_file_count: int, - expected_file_formats: list[str], - with_title: bool, - with_author: bool, - with_emoji: bool - ): +def _run_sticker_convert( + tmp_path: Path, + source: str, + url: str, + expected_file_count: int, + expected_file_formats: list[str], + with_title: bool, + with_author: bool, + with_emoji: bool, +): input_dir = tmp_path / "input" output_dir = tmp_path / "output" - run_cmd(cmd = [ - PYTHON_EXE, - 'sticker-convert.py', - f'--download-{source}', url, - '--input-dir', input_dir, - '--output-dir', output_dir, - '--no-confirm', - '--author', 'sticker-convert-test', - '--title', 'sticker-convert-test' - ], cwd=SRC_DIR) + run_cmd( + cmd=[ + PYTHON_EXE, + "sticker-convert.py", + f"--download-{source}", + url, + "--input-dir", + str(input_dir), + "--output-dir", + str(output_dir), + "--no-confirm", + "--author", + "sticker-convert-test", + "--title", + "sticker-convert-test", + ], + cwd=SRC_DIR, + ) for i in range(expected_file_count): for fmt in expected_file_formats: - fname = str(i).zfill(3) + fmt + fname = Path(str(i).zfill(3) + fmt) assert fname in input_dir.iterdir() - + if with_title: - assert "title.txt" in input_dir.iterdir() + assert Path("title.txt") in input_dir.iterdir() if with_author: - assert "author.txt" in input_dir.iterdir() + assert Path("author.txt") in input_dir.iterdir() if with_emoji: - assert "emoji.txt" in input_dir.iterdir() + assert Path("emoji.txt") in input_dir.iterdir() + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_signal_static_png(tmp_path): +def test_download_signal_static_png(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="signal", url="https://signal.art/addstickers/#pack_id=caf8b92fcadce4b7f33ad949f2d8754b&pack_key=decb0e6cdec7683ee2fe54a291aa1e50db19dbab4a063f0cb1610aeda146698c", expected_file_count=3, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_signal_static_webp(tmp_path): +def test_download_signal_static_webp(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="signal", url="https://signal.art/addstickers/#pack_id=8c865ab7218386ceddbb563681634e22&pack_key=0394dfca57e10a34ea70cad834e343d90831c9f69164b03c813575c34873ef8d", expected_file_count=3, expected_file_formats=[".webp"], with_title=True, with_author=True, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_signal_animated_apng(tmp_path): +def test_download_signal_animated_apng(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="signal", url="https://signal.art/addstickers/#pack_id=842af30fbf4dc7a502dbe15385e6ceb6&pack_key=4a5d6a5de108dc4eb873d900bf31a70ac312046751475a6c42195dbf7b729d48", expected_file_count=3, expected_file_formats=[".apng"], with_title=True, with_author=True, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_telegram_static_webp(tmp_path): +def test_download_telegram_static_webp(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="telegram", url="https://t.me/addstickers/sticker_convert_test_by_laggykillerstickerbot", expected_file_count=3, expected_file_formats=[".webp"], with_title=True, with_author=False, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") @pytest.mark.skipif(not TELEGRAM_TOKEN, reason="No credentials") -def test_download_telegram_animated_webm(tmp_path): +def test_download_telegram_animated_webm(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="telegram", url="https://t.me/addstickers/sticker_convert_test_animated_by_laggykillerstickerbot", expected_file_count=3, expected_file_formats=[".webp"], with_title=True, with_author=False, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") @pytest.mark.skipif(not TELEGRAM_TOKEN, reason="No credentials") -def test_download_telegram_animated_tgs(tmp_path): +def test_download_telegram_animated_tgs(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="telegram", url="https://telegram.me/addstickers/ColoredCats", expected_file_count=30, expected_file_formats=[".tgs"], with_title=True, with_author=False, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") @pytest.mark.skipif(not TELEGRAM_TOKEN, reason="No credentials") -def test_download_telegram_emoji(tmp_path): +def test_download_telegram_emoji(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="telegram", url="https://t.me/addemoji/ragemojis", expected_file_count=39, expected_file_formats=[".webp"], with_title=True, with_author=False, - with_emoji=True + with_emoji=True, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_below_775(tmp_path): +def test_download_line_static_png_below_775(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/1/en", expected_file_count=88, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_no_region_lock(tmp_path): +def test_download_line_static_png_no_region_lock(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/26407/", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_with_region_lock(tmp_path): +def test_download_line_static_png_with_region_lock(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/12320864/zh-Hant", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_officialaccount(tmp_path): +def test_download_line_static_png_officialaccount(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/officialaccount/event/sticker/27404/zh-Hant", expected_file_count=16, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_officialaccount_special(tmp_path): +def test_download_line_static_png_officialaccount_special(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/officialaccount/event/sticker/27239/ja", expected_file_count=16, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng(tmp_path): +def test_download_line_animated_apng(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/8831/", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_sound(tmp_path): +def test_download_line_animated_apng_sound(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/5440/ja", expected_file_count=24, expected_file_formats=[".png", ".m4a"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_popup(tmp_path): +def test_download_line_animated_apng_popup(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/22229788/ja", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_popup_foreground(tmp_path): +def test_download_line_animated_apng_popup_foreground(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/14011294/ja", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_name_text(tmp_path): +def test_download_line_animated_apng_name_text(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/13780/ja", expected_file_count=40, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_per_sticker_text_no_cookies(tmp_path): +def test_download_line_animated_apng_per_sticker_text_no_cookies(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/12188389/ja", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") @pytest.mark.skipif(not LINE_COOKIES, reason="No credentials") -def test_download_line_animated_apng_per_sticker_text_with_cookies(tmp_path): +def test_download_line_animated_apng_per_sticker_text_with_cookies(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/stickershop/product/12188389/ja", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_static_png_emoji(tmp_path): +def test_download_line_static_png_emoji(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/emojishop/product/5f290aa26f7dd32fa145906b/ja", expected_file_count=40, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_line_animated_apng_emoji(tmp_path): +def test_download_line_animated_apng_emoji(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="line", url="https://store.line.me/emojishop/product/6124aa4ae72c607c18108562/ja", expected_file_count=40, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_kakao_static_png(tmp_path): +def test_download_kakao_static_png(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="kakao", url="https://e.kakao.com/t/pretty-all-friends", expected_file_count=32, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_kakao_animated_gif_store_link_no_token(tmp_path): +def test_download_kakao_animated_gif_store_link_no_token(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="kakao", url="https://e.kakao.com/t/lovey-dovey-healing-bear", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") @pytest.mark.skipif(not KAKAO_TOKEN, reason="No credentials") -def test_download_kakao_animated_gif_store_link_with_token(tmp_path): +def test_download_kakao_animated_gif_store_link_with_token(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="kakao", url="https://e.kakao.com/t/lovey-dovey-healing-bear", expected_file_count=24, expected_file_formats=[".webp"], with_title=True, with_author=True, - with_emoji=False + with_emoji=False, ) + @pytest.mark.skipif(not TEST_DOWNLOAD, reason="TEST_DOWNLOAD not set") -def test_download_kakao_animated_gif_share_link(tmp_path): +def test_download_kakao_animated_gif_share_link(tmp_path): # type: ignore _run_sticker_convert( - tmp_path=tmp_path, + tmp_path=tmp_path, # type: ignore source="kakao", url="https://emoticon.kakao.com/items/lV6K2fWmU7CpXlHcP9-ysQJx9rg=?referer=share_link", expected_file_count=24, expected_file_formats=[".png"], with_title=True, with_author=True, - with_emoji=False - ) \ No newline at end of file + with_emoji=False, + ) diff --git a/tests/test_export.py b/tests/test_export.py index cb93d1c..864ad45 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -1,58 +1,73 @@ import os import sys from pathlib import Path +from typing import Optional import pytest -from tests.common import (COMPRESSION_DICT, PYTHON_EXE, SAMPLE_DIR, - SIGNAL_PASSWORD, SIGNAL_UUID, SRC_DIR, - TELEGRAM_TOKEN, TELEGRAM_USERID, run_cmd) +from tests.common import ( + COMPRESSION_DICT, + PYTHON_EXE, + SAMPLE_DIR, + SIGNAL_PASSWORD, + SIGNAL_UUID, + SRC_DIR, + TELEGRAM_TOKEN, + TELEGRAM_USERID, + run_cmd, +) os.chdir(Path(__file__).resolve().parent) -sys.path.append('../src') +sys.path.append("../src") from sticker_convert.utils.media.codec_info import CodecInfo TEST_UPLOAD = os.environ.get("TEST_UPLOAD") -def _run_sticker_convert(tmp_path: Path, preset: str, export: str): + +def _run_sticker_convert(tmp_path: Path, preset: str, export: Optional[str]): preset_dict = COMPRESSION_DICT.get(preset) - cmd = [ + cmd: list[str] = [ PYTHON_EXE, - 'sticker-convert.py', - '--input-dir', SAMPLE_DIR, - '--output-dir', tmp_path, - '--preset', preset, - '--no-confirm', - '--author', 'sticker-convert-test', - '--title', 'sticker-convert-test' + "sticker-convert.py", + "--input-dir", + str(SAMPLE_DIR), + "--output-dir", + str(tmp_path), + "--preset", + preset, + "--no-confirm", + "--author", + "sticker-convert-test", + "--title", + "sticker-convert-test", ] if export: - cmd.append(f'--export-{export}') + cmd.append(f"--export-{export}") run_cmd(cmd, cwd=SRC_DIR) for i in SAMPLE_DIR.iterdir(): preset_dict.get("size_max").get("img") - if i.startswith("static_") and preset_dict.get("fake_vid") == False: + if i.name.startswith("static_") and preset_dict.get("fake_vid") is False: size_max = preset_dict.get("size_max").get("img") - fmt = preset_dict.get("format").get("img") + fmt: str = preset_dict.get("format").get("img") else: size_max = preset_dict.get("size_max").get("vid") fmt = preset_dict.get("format").get("vid") - fname = os.path.splitext(i)[0] + fmt + fname = i.stem + fmt fpath = tmp_path / fname fps, frames, duration = CodecInfo.get_file_fps_frames_duration(fpath) print(f"[TEST] Check if {fname} exists") - assert os.path.isfile(fpath) + assert fpath.is_file() assert os.path.getsize(fpath) < size_max - if i.startswith("animated_"): + if i.name.startswith("animated_"): print(f"[TEST] {fname}: {fps=} {frames=} {duration=}") duration_min = preset_dict.get("duration").get("min") duration_max = preset_dict.get("duration").get("max") @@ -62,84 +77,117 @@ def _run_sticker_convert(tmp_path: Path, preset: str, export: str): if duration_max: assert duration <= duration_max + def _xcode_asserts(tmp_path: Path): iconset = { - 'App-Store-1024x1024pt.png': (1024, 1024), - 'iPad-Settings-29pt@2x.png': (58, 58), - 'iPhone-settings-29pt@2x.png': (58, 58), - 'iPhone-Settings-29pt@3x.png': (87, 87), - 'Messages27x20pt@2x.png': (54, 40), - 'Messages27x20pt@3x.png': (81, 60), - 'Messages32x24pt@2x.png': (64, 48), - 'Messages32x24pt@3x.png': (96, 72), - 'Messages-App-Store-1024x768pt.png': (1024, 768), - 'Messages-iPad-67x50pt@2x.png': (134, 100), - 'Messages-iPad-Pro-74x55pt@2x.png': (148, 110), - 'Messages-iPhone-60x45pt@2x.png': (120, 90), - 'Messages-iPhone-60x45pt@3x.png': (180, 135) + "App-Store-1024x1024pt.png": (1024, 1024), + "iPad-Settings-29pt@2x.png": (58, 58), + "iPhone-settings-29pt@2x.png": (58, 58), + "iPhone-Settings-29pt@3x.png": (87, 87), + "Messages27x20pt@2x.png": (54, 40), + "Messages27x20pt@3x.png": (81, 60), + "Messages32x24pt@2x.png": (64, 48), + "Messages32x24pt@3x.png": (96, 72), + "Messages-App-Store-1024x768pt.png": (1024, 768), + "Messages-iPad-67x50pt@2x.png": (134, 100), + "Messages-iPad-Pro-74x55pt@2x.png": (148, 110), + "Messages-iPhone-60x45pt@2x.png": (120, 90), + "Messages-iPhone-60x45pt@3x.png": (180, 135), } imessage_xcode_dir = tmp_path / "sticker-convert-test" - assert os.path.isfile(imessage_xcode_dir / "sticker-convert-test/Info.plist") + assert Path(imessage_xcode_dir / "sticker-convert-test/Info.plist").is_file() - assert os.path.isfile(imessage_xcode_dir / "sticker-convert-test StickerPackExtension/Info.plist") - assert os.path.isfile(imessage_xcode_dir / "sticker-convert-test StickerPackExtension/Stickers.xcstickers/Contents.json") + assert Path( + imessage_xcode_dir / "sticker-convert-test StickerPackExtension/Info.plist" + ).is_file() + assert Path( + imessage_xcode_dir + / "sticker-convert-test StickerPackExtension/Stickers.xcstickers/Contents.json" + ).is_file() for i in iconset: - assert os.path.isfile(imessage_xcode_dir / "sticker-convert-test StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset" / i) + assert Path( + imessage_xcode_dir + / "sticker-convert-test StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset" + / i + ).is_file() + + assert Path( + imessage_xcode_dir / "sticker-convert-test.xcodeproj/project.pbxproj" + ).is_file() - assert os.path.isfile(imessage_xcode_dir / "sticker-convert-test.xcodeproj/project.pbxproj") @pytest.mark.skipif(not TEST_UPLOAD, reason="TEST_UPLOAD not set") @pytest.mark.skipif(not (SIGNAL_UUID and SIGNAL_PASSWORD), reason="No credentials") -def test_upload_signal_with_upload(tmp_path): - _run_sticker_convert(tmp_path, "signal", "signal") +def test_upload_signal_with_upload(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "signal", "signal") # type: ignore + @pytest.mark.skipif(not TEST_UPLOAD, reason="TEST_UPLOAD not set") @pytest.mark.skipif(not (TELEGRAM_TOKEN and TELEGRAM_USERID), reason="No credentials") -def test_upload_telegram_with_upload(tmp_path): - _run_sticker_convert(tmp_path, "telegram", "telegram") +def test_upload_telegram_with_upload(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "telegram", "telegram") # type: ignore + @pytest.mark.skipif(not TEST_UPLOAD, reason="TEST_UPLOAD not set") @pytest.mark.skipif(not (TELEGRAM_TOKEN and TELEGRAM_USERID), reason="No credentials") -def test_upload_telegram_emoji_with_upload(tmp_path): - _run_sticker_convert(tmp_path, "telegram_emoji", None) +def test_upload_telegram_emoji_with_upload(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "telegram_emoji", None) # type: ignore + + +@pytest.mark.skipif( + TELEGRAM_TOKEN is not None and TELEGRAM_USERID is not None, + reason="With credentials", +) +def test_upload_signal(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "signal", None) # type: ignore + + +@pytest.mark.skipif( + TELEGRAM_TOKEN is not None and TELEGRAM_USERID is not None, + reason="With credentials", +) +def test_upload_telegram(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "telegram", None) # type: ignore + + +@pytest.mark.skipif( + TELEGRAM_TOKEN is not None and TELEGRAM_USERID is not None, + reason="With credentials", +) +def test_upload_telegram_emoji(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "telegram_emoji", None) # type: ignore + + +def test_export_wastickers(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "whatsapp", "whatsapp") # type: ignore + + wastickers_path = Path(tmp_path, f"sticker-convert-test.wastickers") # type: ignore + assert Path(wastickers_path).is_file() -@pytest.mark.skipif(TELEGRAM_TOKEN and TELEGRAM_USERID, reason="With credentials") -def test_upload_signal(tmp_path): - _run_sticker_convert(tmp_path, "signal", None) -@pytest.mark.skipif(TELEGRAM_TOKEN and TELEGRAM_USERID, reason="With credentials") -def test_upload_telegram(tmp_path): - _run_sticker_convert(tmp_path, "telegram", None) +def test_export_line(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "line", None) # type: ignore -@pytest.mark.skipif(TELEGRAM_TOKEN and TELEGRAM_USERID, reason="With credentials") -def test_upload_telegram_emoji(tmp_path): - _run_sticker_convert(tmp_path, "telegram_emoji", None) -def test_export_wastickers(tmp_path): - _run_sticker_convert(tmp_path, "whatsapp", "whatsapp") +def test_export_kakao(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "kakao", None) # type: ignore - wastickers_path = tmp_path / f"sticker-convert-test.wastickers" - assert os.path.isfile(wastickers_path) -def test_export_line(tmp_path): - _run_sticker_convert(tmp_path, "line", None) +def test_export_xcode_imessage_small(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "imessage_small", "imessage") # type: ignore -def test_export_kakao(tmp_path): - _run_sticker_convert(tmp_path, "kakao", None) + _xcode_asserts(tmp_path) # type: ignore -def test_export_xcode_imessage_small(tmp_path): - _run_sticker_convert(tmp_path, "imessage_small", "imessage") - _xcode_asserts(tmp_path) +def test_export_xcode_imessage_medium(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "imessage_medium", "imessage") # type: ignore -def test_export_xcode_imessage_medium(tmp_path): - _run_sticker_convert(tmp_path, "imessage_medium", "imessage") + _xcode_asserts(tmp_path) # type: ignore - _xcode_asserts(tmp_path) -def test_export_xcode_imessage_large(tmp_path): - _run_sticker_convert(tmp_path, "imessage_large", "imessage") +def test_export_xcode_imessage_large(tmp_path): # type: ignore + _run_sticker_convert(tmp_path, "imessage_large", "imessage") # type: ignore - _xcode_asserts(tmp_path) \ No newline at end of file + _xcode_asserts(tmp_path) # type: ignore