FFMpeg-Compressor: Use ffmpeg-python and Pillow instead of ffmpeg cli

This commit is contained in:
OleSTEEP 2023-11-16 01:26:23 +03:00
parent e48b7599d1
commit 37ff1f78b3
6 changed files with 126 additions and 125 deletions

View file

@ -9,17 +9,18 @@ Python utility uses ffmpeg to compress Visual Novel Resources
### Configuration ### Configuration
#### FFMPEG section #### 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. * 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`) * 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 #### AUDIO section
* Extension - Required audio file extension. It supports: `.aac`, `.flac`, `.m4a`, `.mp3`, `.ogg`, `.opus`, `.raw`, `.wav`, `.wma`. * 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. * 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 #### IMAGE section
* Extension - Required image file extension. It supports: `.apng`, `.avif`, `.bmp`, `.jfif`, `.pjpeg`, `.pjp`, `.svg`, `.webp`, `.jpg/.jpeg`, `.png`, `.raw` * Extension - Required image file extension. It supports: `.apng`, `.avif`, `.bmp`, `.tga`, `.tiff`, `.dds`, `.svg`, `.webp`, `.jpg/.jpeg`, `.png`
* CompLevel - Compression level for images. Values range: `0-100` (100 - max compression, 0 - min compression) * Quality - Quality level of images. Values range: `0-100` (100 - best quality, 0 - worst quality)
#### VIDEO section #### VIDEO section
* Extension - Required image file extension. It supports: `.3gp`, `.amv`, `.avi`, `.gif`, `.m2l`, `.m4v`, `.mkv`, `.mov`, `.mp4`, `.m4v`, `.mpeg`, `.mpv`, `.webm`, `.ogv` * 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) ### TODO (for testing branch)
* [x] Recreate whole game directory with compressed files * [x] Recreate whole game directory with compressed files
* [ ] Cross platform (Easy Windows usage and binaries, MacOS binaries) * [ ] 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 * [ ] Reorganize code

View file

@ -1,18 +1,17 @@
[FFMPEG] [FFMPEG]
FFmpegParams = "-n -hide_banner -loglevel quiet"
CopyUnprocessed = false CopyUnprocessed = false
MimicMode = false MimicMode = false
HideErrors = false HideErrors = false
WebpRGBA = true WebpRGBA = true
[AUDIO] [AUDIO]
Extension = "mp3" Extension = "opus"
BitRate = "320k" BitRate = "320k"
[IMAGE] [IMAGE]
Extension = "jpg" Extension = "avif"
CompLevel = 20 Quality = 20
[VIDEO] [VIDEO]
Extension = "mp4" Extension = "webm"
Codec = "libvpx-vp9" Codec = "libvpx-vp9"

View file

@ -8,25 +8,43 @@ import shutil
import sys import sys
import os import os
try:
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:
return "unknown"
if __name__ == "__main__":
try:
if sys.argv[1][len(sys.argv[1])-1] == "/": if sys.argv[1][len(sys.argv[1])-1] == "/":
arg_path = sys.argv[1][:len(sys.argv[1])-1] arg_path = sys.argv[1][:len(sys.argv[1])-1]
else: else:
arg_path = sys.argv[1] arg_path = sys.argv[1]
except IndexError: except IndexError:
print(utils.help_message()) print(utils.help_message())
exit() exit()
orig_folder = arg_path orig_folder = arg_path
printer.orig_folder = arg_path printer.orig_folder = arg_path
printer.bar_init(orig_folder) printer.bar_init(orig_folder)
if os.path.exists(f"{orig_folder}_compressed"): if os.path.exists(f"{orig_folder}_compressed"):
shutil.rmtree(f"{orig_folder}_compressed") shutil.rmtree(f"{orig_folder}_compressed")
printer.info("Creating folders...") printer.info("Creating folders...")
for folder, folders, files in os.walk(orig_folder): for folder, folders, files in os.walk(orig_folder):
if not os.path.exists(folder.replace(orig_folder, f"{orig_folder}_compressed")): if not os.path.exists(folder.replace(orig_folder, f"{orig_folder}_compressed")):
os.mkdir(folder.replace(orig_folder, f"{orig_folder}_compressed")) os.mkdir(folder.replace(orig_folder, f"{orig_folder}_compressed"))
@ -34,27 +52,26 @@ for folder, folders, files in os.walk(orig_folder):
target_folder = folder.replace(orig_folder, f"{orig_folder}_compressed") target_folder = folder.replace(orig_folder, f"{orig_folder}_compressed")
for file in os.listdir(folder): for file in os.listdir(folder):
if os.path.isfile(f'{folder}/{file}'): if os.path.isfile(f'{folder}/{file}'):
match compressor.get_file_type(file): match get_file_type(file):
case "audio": case "audio":
comp_file = compressor.compress_audio(folder, file, target_folder) comp_file = compressor.compress_audio(folder, file, target_folder,
configloader.config['AUDIO']['Extension'])
case "image": case "image":
comp_file = compressor.compress_image(folder, file, target_folder) comp_file = compressor.compress_image(folder, file, target_folder,
configloader.config['IMAGE']['Extension'])
case "video": case "video":
comp_file = compressor.compress_video(folder, file, target_folder) comp_file = compressor.compress_video(folder, file, target_folder,
configloader.config['VIDEO']['Extension'])
case "unknown": case "unknown":
comp_file = compressor.compress(folder, file, target_folder) comp_file = compressor.compress(folder, file, target_folder)
utils.check_file_existing(folder.replace(orig_folder, f"{orig_folder}_compressed"), file)
if configloader.config['FFMPEG']['MimicMode']: if configloader.config['FFMPEG']['MimicMode']:
try: try:
os.rename(comp_file, f'{folder}/{file}'.replace(orig_folder, f"{orig_folder}_compressed")) os.rename(comp_file, f'{folder}/{file}'.replace(orig_folder, f"{orig_folder}_compressed"))
except FileNotFoundError: except FileNotFoundError:
if not configloader.config['FFMPEG']['HideErrors']: pass
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.")
if configloader.config['FFMPEG']['CopyUnprocessed']: if configloader.config['FFMPEG']['CopyUnprocessed']:
printer.info("Copying unprocessed files...") printer.info("Copying unprocessed files...")
utils.add_unprocessed_files(orig_folder) utils.add_unprocessed_files(orig_folder)
utils.get_compression_status(orig_folder) utils.get_compression_status(orig_folder)

View file

@ -1,36 +1,12 @@
from modules import printer
from modules import configloader from modules import configloader
from modules import printer
from modules import utils
from PIL import Image from PIL import Image
import pillow_avif
import ffmpeg
import os 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): def has_transparency(img):
if img.info.get("transparency", None) is not None: if img.info.get("transparency", None) is not None:
return True return True
@ -47,59 +23,74 @@ def has_transparency(img):
return False return False
def compress_audio(folder, file, target_folder): def compress_audio(folder, file, target_folder, extension):
ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams']
bitrate = configloader.config['AUDIO']['BitRate'] bitrate = configloader.config['AUDIO']['BitRate']
printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{bitrate}") printer.files(file, os.path.splitext(file)[0], extension, f"{bitrate}")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -q:a {bitrate} " try:
f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") (ffmpeg
return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' .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): def compress_video(folder, file, target_folder, extension):
ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams']
codec = configloader.config['VIDEO']['Codec'] codec = configloader.config['VIDEO']['Codec']
printer.files(file, os.path.splitext(file)[0], get_req_ext(file), codec) printer.files(file, os.path.splitext(file)[0], extension, codec)
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -vcodec {codec} " try:
f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") (ffmpeg
return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' .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): def compress_image(folder, file, target_folder, extension):
ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams'] quality = configloader.config['IMAGE']['Quality']
comp_level = configloader.config['IMAGE']['CompLevel']
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}')): if (extension == "jpg" or extension == "jpeg" or
printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") (extension == "webp" and not configloader.config['FFMPEG']['WebpRGBA'])):
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...")
elif get_req_ext(file) == "webp" and not configloader.config['FFMPEG']['WebpRGBA']: if has_transparency(Image.open(f'{folder}/{file}')):
if not has_transparency(Image.open(f'{folder}/{file}')): printer.warning(f"{file} has transparency. Changing to png...")
printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") printer.files(file, os.path.splitext(file)[0], "png", f"{quality}%")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} " image.save(f"{target_folder}/{os.path.splitext(file)[0]}.png",
f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") optimize=True,
else: quality=quality)
printer.warning(f"{file} has transparency, but WebP RGBA disabled in config. Changing to png...") return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}'
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'")
else: else:
printer.files(file, os.path.splitext(file)[0], get_req_ext(file), f"{comp_level}%") printer.files(file, os.path.splitext(file)[0], extension, f"{quality}%")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} " image.save(f"{target_folder}/{os.path.splitext(file)[0]}.{extension}",
f"'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}'") optimize=True,
return f'{target_folder}/{os.path.splitext(file)[0]}.{get_req_ext(file)}' quality=quality)
return f'{target_folder}/{os.path.splitext(file)[0]}.{extension}'
def compress(folder, file, target_folder): def compress(folder, file, target_folder):
ffmpeg_params = configloader.config['FFMPEG']['FFmpegParams']
printer.unknown_file(file) 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}' return f'{target_folder}/{file}'

View file

@ -74,14 +74,5 @@ def add_unprocessed_files(orig_folder):
printer.info(f"File {file} copied to compressed 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(): def help_message():
return "Usage: ffmpeg-comp {folder}" return "Usage: ffmpeg-comp {folder}"

View file

@ -1,2 +1,4 @@
Pillow==9.5.0 Pillow==9.5.0
pillow-avif-plugin==1.4.1
ffmpeg-python==0.2.0
progress==1.6 progress==1.6