From 37ff1f78b326f188b1d1743c2cef5e58c0e13c0d Mon Sep 17 00:00:00 2001 From: OleSTEEP Date: Thu, 16 Nov 2023 01:26:23 +0300 Subject: [PATCH] FFMpeg-Compressor: Use ffmpeg-python and Pillow instead of ffmpeg cli --- FFMpeg-Compressor/README.md | 9 +- FFMpeg-Compressor/ffmpeg-comp.toml | 9 +- FFMpeg-Compressor/main.py | 99 +++++++++++-------- FFMpeg-Compressor/modules/compressor.py | 123 +++++++++++------------- FFMpeg-Compressor/modules/utils.py | 9 -- FFMpeg-Compressor/requirements.txt | 2 + 6 files changed, 126 insertions(+), 125 deletions(-) diff --git a/FFMpeg-Compressor/README.md b/FFMpeg-Compressor/README.md index fdb9e83..de7d334 100644 --- a/FFMpeg-Compressor/README.md +++ b/FFMpeg-Compressor/README.md @@ -9,17 +9,18 @@ Python utility uses ffmpeg to compress Visual Novel Resources ### Configuration #### FFMPEG section -* FFMpegParams - Some parameters & flags for ffmpeg command line interface (default: `"-n -hide_banner -loglevel quiet"`) * CopyUnprocessed - Copy all files that failed to compress by ffmpeg to destination folder. In can helps to recreate original folder, but with compressed files. * MimicMode - Rename compressed file to it original name and extension. VN engines determine the file type by its header, so for example PNG file named file.jpg will be loaded as PNG file. (default: `false`) +* HideErrors - Hide some errors about compression. (default: `false`) +* WebpRGBA - Alpha channel in webp. If false switches extension to png. (default: `true`) #### AUDIO section * Extension - Required audio file extension. It supports: `.aac`, `.flac`, `.m4a`, `.mp3`, `.ogg`, `.opus`, `.raw`, `.wav`, `.wma`. * BitRate - (mp3 only, for now) Required audio bitrate. For best quality use `320k` value, but for worse use `1-9` (9 worst) number range. #### IMAGE section -* Extension - Required image file extension. It supports: `.apng`, `.avif`, `.bmp`, `.jfif`, `.pjpeg`, `.pjp`, `.svg`, `.webp`, `.jpg/.jpeg`, `.png`, `.raw` -* CompLevel - Compression level for images. Values range: `0-100` (100 - max compression, 0 - min compression) +* Extension - Required image file extension. It supports: `.apng`, `.avif`, `.bmp`, `.tga`, `.tiff`, `.dds`, `.svg`, `.webp`, `.jpg/.jpeg`, `.png` +* Quality - Quality level of images. Values range: `0-100` (100 - best quality, 0 - worst quality) #### VIDEO section * Extension - Required image file extension. It supports: `.3gp`, `.amv`, `.avi`, `.gif`, `.m2l`, `.m4v`, `.mkv`, `.mov`, `.mp4`, `.m4v`, `.mpeg`, `.mpv`, `.webm`, `.ogv` @@ -28,5 +29,5 @@ Python utility uses ffmpeg to compress Visual Novel Resources ### TODO (for testing branch) * [x] Recreate whole game directory with compressed files * [ ] Cross platform (Easy Windows usage and binaries, MacOS binaries) -* [ ] Use ffmpeg python bindings instead of cli commands +* [x] Use ffmpeg python bindings instead of cli commands * [ ] Reorganize code \ No newline at end of file diff --git a/FFMpeg-Compressor/ffmpeg-comp.toml b/FFMpeg-Compressor/ffmpeg-comp.toml index 1a1794b..f615c54 100644 --- a/FFMpeg-Compressor/ffmpeg-comp.toml +++ b/FFMpeg-Compressor/ffmpeg-comp.toml @@ -1,18 +1,17 @@ [FFMPEG] -FFmpegParams = "-n -hide_banner -loglevel quiet" CopyUnprocessed = false MimicMode = false HideErrors = false WebpRGBA = true [AUDIO] -Extension = "mp3" +Extension = "opus" BitRate = "320k" [IMAGE] -Extension = "jpg" -CompLevel = 20 +Extension = "avif" +Quality = 20 [VIDEO] -Extension = "mp4" +Extension = "webm" Codec = "libvpx-vp9" \ No newline at end of file diff --git a/FFMpeg-Compressor/main.py b/FFMpeg-Compressor/main.py index 5ad3afb..84d86df 100755 --- a/FFMpeg-Compressor/main.py +++ b/FFMpeg-Compressor/main.py @@ -8,53 +8,70 @@ import shutil import sys import os -try: - if sys.argv[1][len(sys.argv[1])-1] == "/": - arg_path = sys.argv[1][:len(sys.argv[1])-1] + +def get_file_type(filename): + audio_ext = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.opus', '.raw', '.wav', '.wma'] + image_ext = ['.apng', '.avif', '.bmp', '.tga', '.tiff', '.dds', '.svg', '.webp', '.jpg', '.jpeg', '.png'] + video_ext = ['.3gp' '.amv', '.avi', '.gif', '.m2t', '.m4v', '.mkv', '.mov', '.mp4', '.m4v', '.mpeg', '.mpv', + '.webm', '.ogv'] + + if os.path.splitext(filename)[1] in audio_ext: + return "audio" + elif os.path.splitext(filename)[1] in image_ext: + return "image" + elif os.path.splitext(filename)[1] in video_ext: + return "video" else: - arg_path = sys.argv[1] -except IndexError: - print(utils.help_message()) - exit() + return "unknown" -orig_folder = arg_path -printer.orig_folder = arg_path -printer.bar_init(orig_folder) +if __name__ == "__main__": + try: + if sys.argv[1][len(sys.argv[1])-1] == "/": + arg_path = sys.argv[1][:len(sys.argv[1])-1] + else: + arg_path = sys.argv[1] + except IndexError: + print(utils.help_message()) + exit() -if os.path.exists(f"{orig_folder}_compressed"): - shutil.rmtree(f"{orig_folder}_compressed") + orig_folder = arg_path + printer.orig_folder = arg_path -printer.info("Creating folders...") -for folder, folders, files in os.walk(orig_folder): - if not os.path.exists(folder.replace(orig_folder, f"{orig_folder}_compressed")): - os.mkdir(folder.replace(orig_folder, f"{orig_folder}_compressed")) + printer.bar_init(orig_folder) - printer.info(f"Compressing \"{folder.replace(orig_folder, orig_folder.split('/').pop())}\" folder...") - target_folder = folder.replace(orig_folder, f"{orig_folder}_compressed") - for file in os.listdir(folder): - if os.path.isfile(f'{folder}/{file}'): - match compressor.get_file_type(file): - case "audio": - comp_file = compressor.compress_audio(folder, file, target_folder) - case "image": - comp_file = compressor.compress_image(folder, file, target_folder) - case "video": - comp_file = compressor.compress_video(folder, file, target_folder) - case "unknown": - comp_file = compressor.compress(folder, file, target_folder) + if os.path.exists(f"{orig_folder}_compressed"): + shutil.rmtree(f"{orig_folder}_compressed") - utils.check_file_existing(folder.replace(orig_folder, f"{orig_folder}_compressed"), file) + printer.info("Creating folders...") + for folder, folders, files in os.walk(orig_folder): + if not os.path.exists(folder.replace(orig_folder, f"{orig_folder}_compressed")): + os.mkdir(folder.replace(orig_folder, f"{orig_folder}_compressed")) - if configloader.config['FFMPEG']['MimicMode']: - try: - os.rename(comp_file, f'{folder}/{file}'.replace(orig_folder, f"{orig_folder}_compressed")) - except FileNotFoundError: - if not configloader.config['FFMPEG']['HideErrors']: - printer.error(f"File {file} can't be processed! Maybe it is ffmpeg error or unsupported file. " - f"You can change -loglevel in ffmpeg parameters to see full error.") + printer.info(f"Compressing \"{folder.replace(orig_folder, orig_folder.split('/').pop())}\" folder...") + target_folder = folder.replace(orig_folder, f"{orig_folder}_compressed") + for file in os.listdir(folder): + if os.path.isfile(f'{folder}/{file}'): + match get_file_type(file): + case "audio": + comp_file = compressor.compress_audio(folder, file, target_folder, + configloader.config['AUDIO']['Extension']) + case "image": + comp_file = compressor.compress_image(folder, file, target_folder, + configloader.config['IMAGE']['Extension']) + case "video": + comp_file = compressor.compress_video(folder, file, target_folder, + configloader.config['VIDEO']['Extension']) + case "unknown": + comp_file = compressor.compress(folder, file, target_folder) -if configloader.config['FFMPEG']['CopyUnprocessed']: - printer.info("Copying unprocessed files...") - utils.add_unprocessed_files(orig_folder) -utils.get_compression_status(orig_folder) + if configloader.config['FFMPEG']['MimicMode']: + try: + os.rename(comp_file, f'{folder}/{file}'.replace(orig_folder, f"{orig_folder}_compressed")) + except FileNotFoundError: + pass + + if configloader.config['FFMPEG']['CopyUnprocessed']: + printer.info("Copying unprocessed files...") + utils.add_unprocessed_files(orig_folder) + utils.get_compression_status(orig_folder) diff --git a/FFMpeg-Compressor/modules/compressor.py b/FFMpeg-Compressor/modules/compressor.py index d29b4ce..c3f833a 100644 --- a/FFMpeg-Compressor/modules/compressor.py +++ b/FFMpeg-Compressor/modules/compressor.py @@ -1,36 +1,12 @@ -from modules import printer from modules import configloader +from modules import printer +from modules import utils from PIL import Image +import pillow_avif +import ffmpeg import os -def get_req_ext(file): - match get_file_type(file): - case "audio": - return configloader.config['AUDIO']['Extension'] - case "image": - return configloader.config['IMAGE']['Extension'] - case "video": - return configloader.config['VIDEO']['Extension'] - - -def get_file_type(file): - audio_ext = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.opus', '.raw', '.wav', '.wma'] - image_ext = ['.apng', '.avif', '.bmp', '.jfif', '.pjpeg', '.pjp', '.svg', '.webp', '.jpg', '.jpeg', '.png', '.raw'] - video_ext = ['.3gp' '.amv', '.avi', '.gif', '.m2t', '.m4v', '.mkv', '.mov', '.mp4', '.m4v', '.mpeg', '.mpv', '.webm', - '.ogv'] - file_extension = os.path.splitext(file)[1] - - if file_extension in audio_ext: - return "audio" - elif file_extension in image_ext: - return "image" - elif file_extension in video_ext: - return "video" - else: - return "unknown" - - def has_transparency(img): if img.info.get("transparency", None) is not None: return True @@ -47,59 +23,74 @@ def has_transparency(img): return False -def compress_audio(folder, file, target_folder): - ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams'] +def compress_audio(folder, file, target_folder, extension): bitrate = configloader.config['AUDIO']['BitRate'] - printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{bitrate}") - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -q:a {bitrate} " - f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") - return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' + printer.files(file, os.path.splitext(file)[0], extension, f"{bitrate}") + try: + (ffmpeg + .input(f'{folder}/{file}') + .output(f'{target_folder}/{os.path.splitext(file)[0]}.{extension}', audio_bitrate=bitrate) + .run(quiet=True) + ) + except ffmpeg._run.Error: + utils.errors_count += 1 + if not configloader.config['FFMPEG']['HideErrors']: + printer.error(f"File {file} can't be processed! Maybe it is ffmpeg error or unsupported file.") + return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}' -def compress_video(folder, file, target_folder): - ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams'] +def compress_video(folder, file, target_folder, extension): codec = configloader.config['VIDEO']['Codec'] - printer.files(file, os.path.splitext(file)[0], get_req_ext(file), codec) - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -vcodec {codec} " - f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") - return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' + printer.files(file, os.path.splitext(file)[0], extension, codec) + try: + (ffmpeg + .input(f'{folder}/{file}') + .output(f'{target_folder}/{os.path.splitext(file)[0]}.{extension}', format=codec) + .run(quiet=True) + ) + except ffmpeg._run.Error: + utils.errors_count += 1 + if not configloader.config['FFMPEG']['HideErrors']: + printer.error(f"File {file} can't be processed! Maybe it is ffmpeg error or unsupported file.") + return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}' -def compress_image(folder, file, target_folder): - ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams'] - comp_level = configloader.config['IMAGE']['CompLevel'] +def compress_image(folder, file, target_folder, extension): + quality = configloader.config['IMAGE']['Quality'] - if get_req_ext(file) == "jpg" or get_req_ext(file) == "jpeg": + image = Image.open(f'{folder}/{file}') - if not has_transparency(Image.open(f'{folder}/{file}')): - printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -q {comp_level/10} " - f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") - else: - printer.warning(f"{file} has transparency (.jpg not support it). Skipping...") + if (extension == "jpg" or extension == "jpeg" or + (extension == "webp" and not configloader.config['FFMPEG']['WebpRGBA'])): - elif get_req_ext(file) == "webp" and not configloader.config['FFMPEG']['WebpRGBA']: - if not has_transparency(Image.open(f'{folder}/{file}')): - printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} " - f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") - else: - printer.warning(f"{file} has transparency, but WebP RGBA disabled in config. Changing to png...") - printer.files(file, os.path.splitext(file)[0], "png", f"{comp_level}%") - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} " - f"'{target_folder}/{os.path.splitext(file)[0]}.png'") + if has_transparency(Image.open(f'{folder}/{file}')): + printer.warning(f"{file} has transparency. Changing to png...") + printer.files(file, os.path.splitext(file)[0], "png", f"{quality}%") + image.save(f"{target_folder}/{os.path.splitext(file)[0]}.png", + optimize=True, + quality=quality) + return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}' else: - printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} " - f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") - return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' + printer.files(file, os.path.splitext(file)[0], extension, f"{quality}%") + image.save(f"{target_folder}/{os.path.splitext(file)[0]}.{extension}", + optimize=True, + quality=quality) + return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}' def compress(folder, file, target_folder): - ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams'] printer.unknown_file(file) - os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} '{target_folder}/{file}'") + try: + (ffmpeg + .input(f'{folder}/{file}') + .output(f'{target_folder}/{file}') + .run(quiet=True) + ) + except ffmpeg._run.Error: + utils.errors_count += 1 + if not configloader.config['FFMPEG']['HideErrors']: + printer.error(f"File {file} can't be processed! Maybe it is ffmpeg error or unsupported file.") return f'{target_folder}/{file}' diff --git a/FFMpeg-Compressor/modules/utils.py b/FFMpeg-Compressor/modules/utils.py index 15ef2cc..08074e7 100644 --- a/FFMpeg-Compressor/modules/utils.py +++ b/FFMpeg-Compressor/modules/utils.py @@ -74,14 +74,5 @@ def add_unprocessed_files(orig_folder): printer.info(f"File {file} copied to compressed folder.") -def check_file_existing(folder, file): - if not len(glob(f"{folder}/{os.path.splitext(file)[0]}.*")): - global errors_count - errors_count += 1 - if not configloader.config['FFMPEG']['HideErrors']: - printer.error(f"{file} not processed. It can be ffmpeg error or file type is unsupported. " - f"You can set '-loglevel error' in ffmpeg config to see full error.") - - def help_message(): return "Usage: ffmpeg-comp {folder}" diff --git a/FFMpeg-Compressor/requirements.txt b/FFMpeg-Compressor/requirements.txt index 290af37..8d1f56a 100644 --- a/FFMpeg-Compressor/requirements.txt +++ b/FFMpeg-Compressor/requirements.txt @@ -1,2 +1,4 @@ Pillow==9.5.0 +pillow-avif-plugin==1.4.1 +ffmpeg-python==0.2.0 progress==1.6