Version 2.0 from testing branch #1

Merged
olesteep merged 105 commits from testing into master 2025-02-18 23:38:05 +03:00
38 changed files with 1402 additions and 334 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/output/
/tests/
/tests_compressed/
/build/
/dist/
/vntools.egg-info/

View file

@ -1,8 +0,0 @@
## FFMpeg-Compressor
Python utility uses ffmpeg to compress Visual Novel Resources
### How to use
* Download `ffmpeg-comp.toml` and put in next to binary or in to `/etc` folder
* Change the configuration of the utility in `ffmpeg-comp.toml` for yourself
* `ffmpeg-comp {folder}`
* In result you get `{folder-compressed}` near with original `{folder}`

View file

@ -1,15 +0,0 @@
[FFMPEG]
FFmpegParams = "-hide_banner -loglevel error"
[AUDIO]
Extension = "mp3"
BitRate = "320k"
[IMAGE]
Extension = "jpg"
CompLevel = 100
JpegComp = 3
[VIDEO]
Extension = "webm"
Codec = "libvpx-vp9"

View file

@ -1,33 +0,0 @@
#!/bin/python3
from modules import compressor
from modules import printer
from modules import utils
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]
else:
arg_path = sys.argv[1]
except IndexError:
print(utils.help_message())
exit()
orig_folder = arg_path
printer.orig_folder = arg_path
printer.bar_init(orig_folder)
if os.path.exists(f"{orig_folder}_compressed"):
shutil.rmtree(f"{orig_folder}_compressed")
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.info(f"Compressing \"{folder.replace(orig_folder, orig_folder.split('/').pop())}\" folder...")
compressor.compress(orig_folder, folder)
utils.get_compression_status(orig_folder)

View file

@ -1,80 +0,0 @@
from modules import printer
from PIL import Image
import tomllib
import os
audio_exts = ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.opus', '.raw', '.wav', '.wma']
image_exts = ['.apng', '.avif', '.jfif', '.pjpeg', '.pjp', '.svg', '.webp', '.jpg', '.jpeg', '.png', '.raw']
video_exts = ['.3gp' '.amv', '.avi', '.gif', '.m4v', '.mkv', '.mov', '.mp4', '.m4v', '.mpeg', '.mpv', '.webm', '.ogv']
try:
config = tomllib.load(open("ffmpeg-comp.toml", "rb"))
except FileNotFoundError:
try:
config = tomllib.load(open("/etc/ffmpeg-comp.toml", "rb"))
except FileNotFoundError:
printer.error("Config file not found. Please put it next to binary or in to /etc folder.")
exit()
ffmpeg_params = config['FFMPEG']['FFmpegParams']
req_audio_ext = config['AUDIO']['Extension']
req_image_ext = config['IMAGE']['Extension']
req_video_ext = config['VIDEO']['Extension']
def has_transparency(img):
if img.info.get("transparency", None) is not None:
return True
if img.mode == "P":
transparent = img.info.get("transparency", -1)
for _, index in img.getcolors():
if index == transparent:
return True
elif img.mode == "RGBA":
extrema = img.getextrema()
if extrema[3][0] < 255:
return True
return False
def compress(root_folder, folder):
target_folder = folder.replace(root_folder, f"{root_folder}_compressed")
for file in os.listdir(folder):
if os.path.isfile(f'{folder}/{file}'):
if os.path.splitext(file)[1] in audio_exts:
bitrate = config['AUDIO']['BitRate']
printer.files(file, os.path.splitext(file)[0], req_audio_ext, f"{bitrate}bit/s")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} "
f"'{target_folder}/{os.path.splitext(file)[0]}.{req_audio_ext}'")
elif os.path.splitext(file)[1] in image_exts:
if req_image_ext == "jpg" or req_image_ext == "jpeg":
if not has_transparency(Image.open(f'{folder}/{file}')):
jpg_comp = config['IMAGE']['JpegComp']
printer.files(file, os.path.splitext(file)[0], req_image_ext, f"level {jpg_comp}")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -q {jpg_comp} "
f"'{target_folder}/{os.path.splitext(file)[0]}.{req_image_ext}'")
else:
printer.warning(f"{file} has transparency (.jpg not support it). Skipping...")
else:
comp_level = config['IMAGE']['CompLevel']
printer.files(file, os.path.splitext(file)[0], req_image_ext, f"{comp_level}%")
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -compression_level {comp_level} "
f"'{target_folder}/{os.path.splitext(file)[0]}.{req_image_ext}'")
elif os.path.splitext(file)[1] in video_exts:
codec = config['VIDEO']['Codec']
printer.files(file, os.path.splitext(file)[0], req_video_ext, codec)
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} -vcodec {codec} "
f"'{target_folder}/{os.path.splitext(file)[0]}.{req_video_ext}'")
else:
printer.warning("File extension not recognized. This may affect the quality of the compression.")
printer.unknown_file(file)
os.system(f"ffmpeg -i '{folder}/{file}' {ffmpeg_params} '{target_folder}/{file}'")

View file

@ -1,42 +0,0 @@
import os
from progress.bar import IncrementalBar
# Fill whole string with spaces for cleaning progress bar
def clean_str(string):
return string + " " * (os.get_terminal_size().columns - len(string))
def info(string):
print(clean_str(f"\r\033[0;32m[INFO]\033[0m {string}"))
def warning(string):
print(clean_str(f"\r\033[0;33m[WARNING]\033[0m {string}"))
def error(string):
print(clean_str(f"\r\033[0;31m[ERROR]\033[0m {string}"))
def bar_init(folder):
file_count = 0
for folder, folders, file in os.walk(folder):
file_count += len(file)
global bar
bar = IncrementalBar('Compressing', max=file_count, suffix='[%(index)d/%(max)d] (%(percent).1f%%) - ETA: %(eta)ds')
def files(source, dest, dest_ext, comment):
source_ext = os.path.splitext(source)[1]
source_name = os.path.splitext(source)[0]
print(clean_str(f"\r[COMP] \033[0;32m{source_name}\033[0m{source_ext}\033[0;32m -> {dest}\033[0m.{dest_ext}\033[0;32m ({comment})\033[0m"))
bar.next()
def unknown_file(file):
print(clean_str(f"\r[COMP] \033[0;33m{file}\033[0m"))
bar.next()

View file

@ -1,51 +0,0 @@
from modules import printer
import os
def get_dir_size(directory, files):
total_size = 0
for folder, folders, files in os.walk(directory):
for file in files:
if not os.path.islink(f"{folder}/{file}"):
total_size += os.path.getsize(f"{folder}/{file}")
return total_size
def get_compression(orig, comp):
processed_files = []
for folder, folders, files in os.walk(comp):
for file in files:
processed_files.append(file)
try:
comp = 100 - int((get_dir_size(comp, processed_files) / get_dir_size(orig, processed_files)) * 100)
if comp < 0:
printer.warning(f'Compression: {comp}%')
printer.warning("The resulting files are larger than the original ones!")
else:
printer.info(f'Compression: {comp}%')
except ZeroDivisionError:
printer.warning("Nothing compressed!")
def get_compression_status(orig_folder):
orig_folder_len = 0
comp_folder_len = 0
for folder, folders, file in os.walk(orig_folder):
orig_folder_len += len(file)
for folder, folders, file in os.walk(f'{orig_folder}_compressed'):
comp_folder_len += len(file)
if orig_folder_len == comp_folder_len:
printer.info("Success!")
get_compression(orig_folder, f"{orig_folder}_compressed")
else:
printer.warning("Some files failed to compress!")
get_compression(orig_folder, f"{orig_folder}_compressed")
def help_message():
text = "Usage: ffmpeg-comp {folder}"
return text

View file

@ -1,2 +0,0 @@
Pillow==9.5.0
progress==1.6

31
PKGBUILD Normal file
View file

@ -0,0 +1,31 @@
# Maintainer: D. Can Celasun <can[at]dcc[dot]im>
# Contributor: Ezekiel Bethel <mctinfoilball@gmail.com>
_pkgname=VNTools
pkgname=vntools-git
pkgver=2.0.e5bf961
pkgrel=1
pkgdesc="Collection of tools used by VienDesu! Porting Team"
arch=("any")
url="https://github.com/VienDesuPorting/VNTools"
depends=("python" "python-pillow" "python-pillow-avif-plugin" "python-python-ffmpeg" "python-progress" "python-colorama")
makedepends=("python-setuptools" "git")
provides=("vntools")
source=("git+${url}.git#branch=testing")
sha256sums=("SKIP")
pkgver() {
cd "${srcdir}/${_pkgname}"
printf "2.0.%s" "$(git rev-parse --short HEAD)"
}
build() {
cd "${srcdir}/${_pkgname}"
python -m build --wheel --no-isolation
}
package() {
cd "${srcdir}/${_pkgname}"
python -m installer --destdir="${pkgdir}" dist/*.whl
}

View file

@ -1,7 +1,23 @@
## VNTools
Collection of tools used by administrators from VN Telegram Channel
Collection of tools used by VienDesu! Porting Team
### Tools
* `FFMpeg-Compressor` - Python utility uses ffmpeg to compress Visual Novel Resources
* `RenPy-Android-Unpack` - Simple Python script for unpacking Ren'Py based .apk files for later rebuilding in the Ren'Py SDK
* `RenPy-Unpacker` - Simple .rpy script that will make any RenPy game unpack itself
* `vnrecode` - Python utility to compress Visual Novel Resources
* `unrenapk` - A Python script for extracting game project from Ren'Py based .apk and .obb files
* `renpy-ripper` - Simple .rpy script that will make any RenPy game unpack itself
* `vnds2renpy` - Simple script for converting VNDS engine scripts to .rpy ones
### Installation
#### Download from releases:
* Windows - `TODO`
* Linux - `TODO`
* MacOS - `TODO`
#### Build tools as binaries:
* Run `./build.sh` on UNIX
* Run `.\build.bat` for Windows
#### Install as python package:
* Run `pip install -U .` command in project folder
* Arch Linux - `paru -Bi .`
* NixOS - `TODO`

View file

@ -1,6 +0,0 @@
## RenPy-Android-Unpack
A simple Python script for unpacking Ren'Py based .apk files for later rebuilding in the Ren'Py SDK
### How to use
* Put some .apk files in folder
* `python3 unpack.py`

View file

@ -1,67 +0,0 @@
#!/bin/python3
import zipfile
import os
import shutil
def extract_assets(file):
with zipfile.ZipFile(file, 'r') as zip_ref:
for content in zip_ref.namelist():
if content.split('/')[0] == 'assets':
zip_ref.extract(content)
if os.path.splitext(file)[1] == '.apk':
try:
zip_ref.extract('res/mipmap-xxxhdpi-v4/icon_background.png', 'assets')
zip_ref.extract('res/mipmap-xxxhdpi-v4/icon_foreground.png', 'assets')
os.rename('assets/res/mipmap-xxxhdpi-v4/icon_background.png', 'assets/android-icon_background.png')
os.rename('assets/res/mipmap-xxxhdpi-v4/icon_foreground.png', 'assets/android-icon_foreground.png')
except KeyError:
zip_ref.extract('res/drawable/icon.png', 'assets')
os.rename('assets/res/drawable/icon.png', 'assets/icon.png')
def rename_files(directory):
for dir_ in os.walk(directory):
for file in dir_[2]:
path = f'{dir_[0]}/{file}'
folder = '/'.join(path.split('/')[:len(path.split('/')) - 1])
newname = f'{path.split("/").pop().replace("x-", "")}'
os.rename(path, f'{folder}/{newname}')
def rename_dirs(directory):
dirs = []
for dir_ in os.walk(directory):
dirs.append(dir_[0])
dirs.reverse()
dirs.pop()
for dir__ in dirs:
folder = '/'.join(dir__.split('/')[:len(dir__.split('/')) - 1])
newname = f'{dir__.split("/").pop().replace("x-", "")}'
os.rename(dir__, f'{folder}/{newname}')
if __name__ == '__main__':
for filename in os.listdir(os.getcwd()):
renpy_warn = 0
if os.path.splitext(filename)[1] == '.apk' or os.path.splitext(filename)[1] == '.obb':
print(f'[INFO] Extracting assets from {filename}... ', end='')
extract_assets(filename)
print('Done')
print('[INFO] Renaming game assets... ', end='')
rename_files('assets')
rename_dirs('assets')
print('Done')
print('[INFO] Removing unneeded files... ', end='')
try:
shutil.rmtree('assets/renpy')
except FileNotFoundError:
renpy_warn = 1
if os.path.splitext(filename)[1] == '.apk':
shutil.rmtree('assets/res')
print('Done')
if renpy_warn:
print("[WARN] File does not contain renpy folder!")
print('[INFO] Renaming directory... ', end='')
os.rename('assets', f'{os.path.splitext(filename)[0]}')
print('Done')

View file

@ -1,8 +0,0 @@
## RenPy-Unpacker
Simple .rpy script that will make any RenPy game unpack itself
### How to use
* Put .rpyc from releases page to game's `game` folder
* Open your game and wait until it not be launched
* Unpacked assets will be in `unpack` folder near with game's executable
* Enjoy!

View file

@ -1,12 +0,0 @@
init 4 python:
import os
for asset in renpy.list_files():
if os.path.splitext(asset)[1] != ".rpa" and asset != "unpack.rpyc":
output = "unpack/game/" + asset
if not os.path.exists(os.path.dirname(output)):
os.makedirs(os.path.dirname(output))
out_bytes = open(output, "wb")
out_bytes.write(renpy.file(asset).read())
out_bytes.close()

23
build.bat Executable file
View file

@ -0,0 +1,23 @@
@Echo off
if not defined VIRTUAL_ENV goto :venv_error
mkdir output
mkdir output\bin
python -m pip install -r requirements.txt || goto :exit
python -m pip install Nuitka || goto :exit
python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-imports --onefile --output-filename=vnrecode vnrecode || goto :exit
xcopy vnrecode\vnrecode.toml output\bin /Y
move /Y output\vnrecode.exe output\bin
python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-imports --onefile --output-filename=unrenapk unrenapk || goto :exit
move /Y output\unrenapk.exe output\bin
python -m nuitka --jobs=%NUMBER_OF_PROCESSORS% --output-dir=output --follow-imports --onefile --output-filename=vnds2renpy vnds2renpy || goto :exit
move /Y output\vnds2renpy.exe output\bin
echo "Done! You can get binaries into output\bin directory"
:venv_error
echo "Please create and activate venv before running this script: python -m venv .\venv && .\venv\Scripts\activate.bat"
goto :exit
:exit
pause
exit /b %exitlevel%

View file

@ -1,6 +1,24 @@
#!/bin/bash
mkdir output
mkdir output/bin
nuitka3 --jobs=$(nproc) --output-dir=output --follow-imports --output-filename=output/bin/ffmpeg-comp FFMpeg-Compressor/main.py
cp FFMpeg-Compressor/ffmpeg-comp.toml output/bin/
nuitka3 --jobs=$(nproc) --output-dir=output --follow-imports --output-filename=output/bin/rendroid-unpack RenPy-Android-Unpack/unpack.py
#!/usr/bin/env bash
set -e
if [[ "$VIRTUAL_ENV" == "" ]]
then
echo -e "Please create and activate venv before running this script: \033[100mpython3 -m venv venv && source ./venv/bin/activate\033[49m"
exit
fi
mkdir -p output
mkdir -p output/bin
python3 -m pip install -r requirements.txt
python3 -m pip install Nuitka
case "$(uname -s)" in
Linux*) jobs="--jobs=$(nproc)";;
Darwin*) jobs="--jobs=$(sysctl -n hw.ncpu)";;
esac
python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --output-filename=vnrecode vnrecode
cp vnrecode/vnrecode.toml output/bin
mv output/vnrecode output/bin
python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --output-filename=unrenapk unrenapk
mv output/unrenapk output/bin
python3 -m nuitka "${jobs}" --output-dir=output --onefile --follow-imports --output-filename=vnds2renpy vnds2renpy
mv output/vnds2renpy output/bin
echo "Done! You can get binaries into output/bin directory"

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[build-system]
requires = [
"setuptools >= 61.0"
]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["vnrecode", "unrenapk", "vnds2renpy"]
include-package-data = true
[tool.setuptools.package-data]
'vnrecode' = ['*.py']
'vnds2renpy' = ['*.py']
'unrenapk' = ['*.py']
[project.scripts]
vnrecode = "vnrecode.__main__:init"
vnds2renpy = "vnds2renpy.__main__:main"
unrenapk = "unrenapk.application:launch"
[project]
name = "vntools"
version = "2.0-dev"
requires-python = ">= 3.11"
dependencies = [
"Pillow>=10.3.0",
"pillow-avif-plugin>=1.4.3",
"python-ffmpeg>=2.0.12",
"progress>=1.6",
"colorama>=0.4.6",
"argparse>=1.4.0"
]

10
renpy-ripper/README.md Normal file
View file

@ -0,0 +1,10 @@
## RenPy-Unpacker
Simple .rpy script that will make any RenPy game unpack itself
### How to use
* Put .rpyc file from releases page to `game` folder
* Open your game and wait until it not be fully loaded
* Extracted assets will be in `unpack` folder near with game's executable
* Enjoy!
It can help with getting assets from encrypted .rpa files with custom encryption.

17
renpy-ripper/ripper.rpy Normal file
View file

@ -0,0 +1,17 @@
init 4 python:
import os
for asset in renpy.list_files():
if os.path.splitext(asset)[1] != ".rpa" and not asset.count("unpack.rpy"): # Ignore .rpa and script itself
if renpy.macintosh:
game_path = os.path.expanduser('~') + "/" + config.name # Unpack assets to home folder (on mac you cant get cwd)
output = game_path + "/game/" + asset
else:
output = "unpack/game/" + asset # Unpack assets to game folder
if not os.path.exists(os.path.dirname(output)):
os.makedirs(os.path.dirname(output))
out_bytes = open(output, "wb")
out_bytes.write(renpy.file(asset).read())
out_bytes.close()

6
requirements.txt Normal file
View file

@ -0,0 +1,6 @@
Pillow>=10.3.0
pillow-avif-plugin>=1.4.3
python-ffmpeg>=2.0.12
progress>=1.6
colorama>=0.4.6
argparse>=1.4.0

6
unrenapk/README.md Normal file
View file

@ -0,0 +1,6 @@
## unrenapk
A simple Python script for unpacking Ren'Py based .apk and .obb files to ready to use Ren'Py SDK's Project
### How to use
* Put some .apk & .obb files in folder
* `unrenapk` (It unpacks all .apk and .obb files in the directory where it is located)

0
unrenapk/__init__.py Normal file
View file

6
unrenapk/__main__.py Executable file
View file

@ -0,0 +1,6 @@
#!/usr/bin/env python3
from unrenapk import application
if __name__ == '__main__':
application.launch()

98
unrenapk/actions.py Executable file
View file

@ -0,0 +1,98 @@
from zipfile import ZipFile, BadZipFile
from PIL import Image
import shutil
import os
from unrenapk.printer import Printer
class Extract:
def __init__(self, output: str):
self.output = output
@staticmethod
def folder(zip_ref: ZipFile, path: str, dest: str):
for content in zip_ref.namelist():
if content.split('/')[0] == path:
zip_ref.extract(content, dest)
@staticmethod
def icon(directory: str):
icons = []
for folder, folders, files in os.walk(directory):
for file in os.listdir(folder):
if os.path.splitext(file)[1] == ".png":
image = Image.open(f"{folder}/{file}")
if image.size[0] == 432 and image.size[1] == 432:
icons.append(f"{folder}/{file}")
if len(icons) == 0:
raise KeyError
return icons
def assets(self, file: str):
try:
with ZipFile(file, 'r') as zip_ref:
self.folder(zip_ref, 'assets', self.output)
if os.path.splitext(file)[1] == '.apk':
try:
# ~Ren'Py 8, 7
self.folder(zip_ref, 'res', os.path.join(self.output, 'assets'))
for icon in self.icon(os.path.join(self.output, 'assets/res')):
os.rename(icon, os.path.join(self.output, "assets", os.path.split(icon)[1]))
except KeyError:
try:
# ~Ren'Py 6
zip_ref.extract('res/drawable/icon.png', os.path.join(self.output, 'assets'))
os.rename(os.path.join(self.output, 'assets/res/drawable/icon.png'),
os.path.join(self.output, 'assets/icon.png'))
except KeyError:
Printer.warn("Icon not found. Maybe it is not supported apk?")
except BadZipFile:
Printer.err("Cant extract .apk file!")
class Rename:
def __init__(self, output):
self.output = output
def files(self, directory: str):
for dir_ in os.walk(os.path.join(self.output, directory)):
for file in dir_[2]:
path = f'{dir_[0]}/{file}'
folder = '/'.join(path.split('/')[:len(path.split('/')) - 1])
newname = f'{path.split("/").pop().replace("x-", "")}'
os.rename(path, f'{folder}/{newname}')
def dirs(self, directory: str):
dirs = []
for dir_ in os.walk(os.path.join(self.output, directory)):
dirs.append(dir_[0])
dirs.reverse()
dirs.pop()
for dir__ in dirs:
folder = '/'.join(dir__.split('/')[:len(dir__.split('/')) - 1])
newname = f'{dir__.split("/").pop().replace("x-", "")}'
os.rename(dir__, f'{folder}/{newname}')
class Actions:
def __init__(self, output: str):
self.output = output
def extract(self) -> Extract:
return Extract(self.output)
def rename(self) -> Rename:
return Rename(self.output)
def clean(self, names: list, ignore: bool):
for name in names:
name = os.path.join(self.output, name)
try:
shutil.rmtree(name)
except FileNotFoundError:
if not ignore:
Printer.warn(f"Path {name} not found!")

51
unrenapk/application.py Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
import colorama
import argparse
import sys
import os
from unrenapk.printer import Printer
from unrenapk.actions import Actions
def args_init():
parser = argparse.ArgumentParser(
prog='unrenapk',
description='Extract Ren\'Py .apk and .obb files into Ren\'Py SDK\'s project'
)
parser.add_argument('path')
parser.add_argument('-o', '--output')
return parser.parse_args()
def launch():
if sys.platform == "win32":
colorama.init()
args = args_init()
if args.output:
output = args.output
else:
output = ''
actions = Actions(output)
printer = Printer()
filename = args.path
if os.path.splitext(filename)[1] == '.apk' or os.path.splitext(filename)[1] == '.obb':
actions.clean(['assets'], True)
printer.info(f'Extracting assets from {filename}... ')
actions.extract().assets(filename)
printer.info('Renaming game assets... ')
actions.rename().files('assets')
actions.rename().dirs('assets')
printer.info('Removing unneeded files... ')
if os.path.splitext(filename)[1] == '.apk':
actions.clean(['assets/renpy', 'assets/res'], False)
actions.clean(['assets/dexopt'], True)
printer.info('Renaming directory... ')
actions.clean([os.path.splitext(filename)[0]], True)
os.rename(os.path.join(output, 'assets'), os.path.splitext(filename)[0])
else:
Printer.err("It's not an .apk or .obb file!")

14
unrenapk/printer.py Normal file
View file

@ -0,0 +1,14 @@
class Printer:
@staticmethod
def info(msg: str):
print(f"\033[100m[INFO] {msg}\033[49m")
@staticmethod
def warn(msg: str):
print(f"\033[93m[WARN]\033[0m {msg}\033[49m")
@staticmethod
def err(msg: str):
print(f"\033[31m[ERROR]\033[0m {msg} Exiting...\033[49m")
exit()

7
vnds2renpy/README.md Normal file
View file

@ -0,0 +1,7 @@
## vnds2renpy
Simple script for converting vnds scripts to rpy
### How to use
* Extract VNDS visual novel's archive and get `scripts` folder from these (or `script.zip` if scripts folder is empty)
* Launch `vnds2renpy` (It will automatically extract `scripts.zip` archive (if it needed) and converts .scr scripts to .rpy)

0
vnds2renpy/__init__.py Normal file
View file

351
vnds2renpy/__main__.py Normal file
View file

@ -0,0 +1,351 @@
#!/usr/bin/env python3
#
# Automatically converts VNDS to Ren'Py.
import os
import zipfile
from io import open
# Sets of variables found in the game.
global_variables = set()
game_variables = set()
def unjp(s):
print(s)
white = "QWERTYUIOPASDFGHJKLZXCVBNMqwertyuiopasdfghjklzxcvbnm1234567890"
for a in s:
if a not in white:
s = s.replace(a, str(ord(a)))
if s[0].isdigit():
s = "var" + s
print(s)
return s
def scan_gsetvar(l):
l = l.split()[0]
l = unjp(l)
global_variables.add(l)
def scan_setvar(l):
if l[0] == "~":
return
l = l.split()[0]
l = unjp(l)
game_variables.add(l)
with zipfile.ZipFile("script.zip", 'r') as zip_ref:
zip_ref.extractall()
def scan_script(infn):
inf = open("script/" + infn, encoding="utf-8")
for l in inf:
l = l.replace("\xef\xbb\xbf", "")
l = l.strip()
if not l:
continue
if " " in l:
command, l = l.split(' ', 1)
else:
command = l
l = ""
if "scan_" + command in globals():
globals()["scan_" + command](l)
inf.close()
class ConvertState(object):
def __init__(self, outf, shortfn=""):
self.outf = outf
self.depth = 0
self.shortfn = shortfn
self.textbuffer = ""
self.empty_block = False
self.imindex = 0
def write(self, s, *args):
self.outf.write(" " * self.depth + "\n")
line = " " * self.depth + s % args + "\n"
if isinstance(line, str):
line = line.encode("utf-8")
self.outf.write(line.decode("utf-8"))
self.empty_block = False
def indent(self):
self.depth += 1
def outdent(self):
self.depth -= 1
def convert_endscript(cs, l):
cs.write("return")
def convert_label(cs, l):
l = l.replace("*", "_star_")
l = l.replace("-", "_")
l = cs.shortfn + "_" + l
cs.outdent()
cs.write("label %s:", l)
cs.indent()
def convert_goto(cs, l):
l = l.replace("*", "_star_")
l = cs.shortfn + "_" + l
l = l.replace("-", "_")
cs.write("jump %s", l)
def convert_choice(cs, l):
choices = l.split("|")
cs.write("menu:")
cs.indent()
for i, c in enumerate(choices):
cs.write("%r:", c)
cs.indent()
cs.write("$ selected = %d", i + 1)
cs.outdent()
cs.outdent()
def convert_setvar(cs, l):
if l[0] == "~":
return
print(f"l = {l}")
if l == 'chain = "main.scr start"':
var, op, val = "chain", "=", "main.scr start"
else:
var, op, val = l.split()
var = unjp(var)
if op == "=":
cs.write("$ %s = %s", var, val)
elif op == "+":
cs.write("$ %s += %s", var, val)
elif op == "-":
cs.write("$ %s -= %s", var, val)
else:
raise Exception("Unknown operation " + op)
def convert_gsetvar(cs, l):
if l[0] == "~":
return
var, op, val = l.split()
var = unjp(var)
if op == "=":
cs.write("$ persistent.%s = %s", var, val)
elif op == "+":
cs.write("$ persistent.%s += %s", var, val)
elif op == "-":
cs.write("$ persistent.%s -= %s", var, val)
else:
raise Exception("Unknown operation " + op)
def convert_if(cs, l):
var, rest = l.strip().split(' ', 1)
var = unjp(var)
if var in global_variables:
var = "persistent." + var
cs.write("if %s %s:", var, rest)
cs.indent()
cs.empty_block = True
def convert_fi(cs, l):
if cs.empty_block:
cs.write("pass")
cs.outdent()
def convert_sound(cs, l):
if l == "~":
cs.write("stop sound")
else:
l = "sound/" + l
cs.write("play sound \"%s\"", l)
def convert_setimg(cs, l):
fn, x, y = l.split()
x = int(x)
y = int(y)
fn = "foreground/" + fn
cs.write("show expression %r as i%d at fgpos(%d, %d)", fn, cs.imindex, x, y)
cs.imindex += 1
def convert_delay(cs, l):
try:
t = int(l) / 60.0
cs.write("pause %f", t)
except:
pass
def convert_bgload(cs, fn):
if " " in fn:
fn, delay = fn.split(" ", 1)
delay = int(delay) / 60.0
else:
delay = 0.5
assert " " not in fn
cs.write("nvl clear")
fn = "background/" + fn
cs.write("scene expression %r", fn)
cs.imindex = 0
if delay:
cs.write("with Dissolve(%f)", delay)
def convert_jump(cs, fn):
if " " in fn:
fn, l = fn.split(" ", 1)
l = l.replace("*", "_star_")
l = l.replace("-", "_")
fn = fn.replace(".scr", "").replace("-", "_")
cs.write("jump %s_%s", fn, l)
return
fn = fn.replace(".scr", "").replace("-", "_")
cs.write("jump %s", fn)
def convert_music(cs, fn):
if " " in fn:
fn, loops = fn.split(" ", 1)
else:
loops = 0
if int(loops) > 0:
noloop = " noloop"
else:
noloop = ""
if fn == "~":
cs.write("stop music")
else:
fn = "sound/" + fn
cs.write("play music %r%s", fn, noloop)
def convert_text(cs, text):
while text and (text[0] == "~" or text[0] == "!"):
text = text[1:]
if not text:
return
if text[0] == "@":
cs.textbuffer += text[1:] + "\n"
return
text = cs.textbuffer + text
text = text.replace("\\", "\\\\")
text = text.replace("\"", "\\\"")
text = text.replace("\n", "\\n")
cs.write('"%s"', text)
cs.textbuffer = ""
def convert_script(infn):
dir_rp = "rpy/"
if not os.path.exists(dir_rp):
os.mkdir(dir_rp)
if os.path.exists(dir_rp):
shortfn = infn.replace(".scr", "")
shortfn = shortfn.replace("-", "_")
inf = open("script/" + infn, encoding="utf-8")
outf = open (dir_rp + shortfn + ".rpy", "w", encoding = "utf-8")
cs = ConvertState(outf, shortfn)
cs.write("label %s:", shortfn)
cs.indent()
for l in inf:
l = l.replace("\xef\xbb\xbf", "")
l = l.strip()
if not l:
continue
if l[0] == "#":
continue
if " " in l:
command, l = l.split(' ', 1)
else:
command = l
l = ""
if "convert_" + command in globals():
globals()["convert_" + command](cs, l)
else:
print("Unknown command", repr(command), repr(l), repr(infn))
outf.close()
def main():
for i in os.listdir("script"):
if not i.endswith(".scr"):
continue
scan_script(i)
for i in os.listdir("script"):
if not i.endswith(".scr"):
continue
convert_script(i)
outf = open("rpy/_start.rpy", "w", encoding="utf-8")
cs = ConvertState(outf)
cs.write("init python:")
cs.indent()
for i in global_variables:
i = unjp(i)
cs.write("if persistent.%s is None:", i)
cs.indent()
cs.write("persistent.%s = 0", i)
cs.outdent()
# prevent non-empty block.
cs.write("pass")
cs.outdent()
cs.write("label start:")
cs.indent()
for i in game_variables:
i = unjp(i)
cs.write("$ %s = 0", i)
cs.write("window show")
cs.write("jump main")
cs.outdent()
outf.close()
if __name__ == "__main__":
main()

59
vnrecode/README.md Normal file
View file

@ -0,0 +1,59 @@
## vnrecode
Python utility uses Pillow and ffmpeg to compress Visual Novel Resources
### Configuration file
#### FFMPEG section
* CopyUnprocessed - Copy all files that failed to compress by ffmpeg to destination folder. In can help to recreate original folder, but with compressed files. (default: `true`)
* ForceCompress - Force try to compress all files in directory via ffmpeg. (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: `true`)
* HideErrors - Hide some errors about compression. (default: `true`)
* 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 - Required audio bitrate. For best quality use `320k` value.
#### IMAGE section
* ResDownScale - Downscale image resolution count. (default: `1`)
* Extension - Required image file extension. It supports: `.apng`, `.avif`, `.bmp`, `.tga`, `.tiff`, `.dds`, `.svg`, `.webp`, `.jpg/.jpeg`, `.png`
* FallBackExtension - Extension if current format does not support RGBA.
* Lossless - Enables lossless compression for supported formats. With this quality parameter means quality of compression. (default: `false`)
* Quality - Quality level of images. Values range: `0-100` (100 - best quality, 0 - worst quality)
#### VIDEO section
* CRF ("Constant Quality") - Video quality parameter for ffmpeg. The CRF value can be from 0 to 63. Lower values mean better quality. Recommended values range from 15 to 35, with 31 being recommended for 1080p HD video. (default: `27`)
* SkipVideo - Skip processing all video files. (default: `false`)
* Extension - Required image file extension. It supports: `.3gp`, `.amv`, `.avi`, `.gif`, `.m2l`, `.m4v`, `.mkv`, `.mov`, `.mp4`, `.m4v`, `.mpeg`, `.mpv`, `.webm`, `.ogv`
* Codec - (Maybe optional in future) Required video codec. (See official ffmpeg documentation for supported codecs)
### CLI Parameters
##### positional arguments:
* source - Directory with game files to recode
##### options:
* ` -h, --help ` - show this help message and exit
* ` -c CONFIG ` - Utility config file
* ` -nu ` - Don't copy unprocessed
* ` -f, --force ` - Try to recode unknown files
* ` -nm, --no-mimic ` - Disable mimic mode
* ` -v, --show_errors ` - Show recode errors
* ` --webp-rgb ` - Recode .webp without alpha channel
* ` -j JOBS, --jobs JOBS ` - Number of threads
* ` -ae A_EXT ` - Audio extension
* ` -ab A_BIT ` - Audio bit rate
* ` -id I_DOWN ` - Image resolution downscale multiplier
* ` -ie I_EXT ` - Image extension
* ` -ife I_FALLEXT ` - Image fallback extension
* ` -il ` - Image losing compression mode
* ` -iq I_QUALITY ` - Image quality
* ` --v_crf V_CRF ` - Video CRF number
* ` -vs ` - Skip video recoding
* ` -ve V_EXT ` - Video extension
* ` -vc V_CODEC ` - Video codec name
### TODO (for testing branch)
* [x] Recreate whole game directory with compressed files
* [x] Cross-platform (Easy Windows usage and binaries, macOS binaries)
* [x] Use ffmpeg python bindings instead of os.system
* [x] Multithread
* [ ] Reorganize code

0
vnrecode/__init__.py Normal file
View file

23
vnrecode/__main__.py Normal file
View file

@ -0,0 +1,23 @@
#!/usr/bin/env python3
from vnrecode.application import Application
from vnrecode.compress import Compress
from vnrecode.printer import Printer
from vnrecode.params import Params
from vnrecode.utils import Utils
def init():
"""
This function creates all needed class instances and run utility
:return: None
"""
params = Params.setup()
printer = Printer(params.source)
utils = Utils(params, printer)
compress = Compress(params, printer, utils)
Application(params, compress, printer, utils).run()
if __name__ == "__main__":
init()

59
vnrecode/application.py Executable file
View file

@ -0,0 +1,59 @@
#!/usr/bin/env python3
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime
from pathlib import Path
import shutil
import os
from vnrecode.compress import Compress
from vnrecode.printer import Printer
from vnrecode.params import Params
from vnrecode.utils import Utils
class Application:
"""
Main class for utility
"""
def __init__(self, params_inst: Params, compress_inst: Compress, printer_inst: Printer, utils_inst: Utils):
self.__params = params_inst
self.__compress = compress_inst.compress
self.__printer = printer_inst
self.__utils = utils_inst
def run(self):
"""
Method creates a folder in which all the recoded files will be placed,
creates a queue of recoding processes for each file and, when the files are run out in the original folder,
calls functions to display the result
:return: None
"""
start_time = datetime.now()
self.__printer.win_ascii_esc()
source = self.__params.source
if self.__params.dest.exists():
shutil.rmtree(self.__params.dest)
self.__printer.info("Creating folders...")
for folder, folders, files in os.walk(source):
output = self.__utils.get_comp_subdir(folder)
if not output.exists():
os.mkdir(output)
self.__printer.info(f'Compressing "{folder}" folder...')
with ThreadPoolExecutor(max_workers=self.__params.workers) as executor:
futures = [
executor.submit(self.__compress, Path(folder, file), Path(output))
for file in files if Path(folder, file).is_file()
]
for future in as_completed(futures):
future.result()
self.__utils.print_duplicates()
self.__utils.get_recode_status()
self.__utils.sys_pause()
print(f"Time taken: {datetime.now() - start_time}")

197
vnrecode/compress.py Normal file
View file

@ -0,0 +1,197 @@
from ffmpeg import FFmpeg, FFmpegError
from pathlib import Path
from PIL import Image
import pillow_avif
from vnrecode.printer import Printer
from vnrecode.params import Params
from vnrecode.utils import Utils
class File:
"""
Class contains some methods to work with files
"""
@staticmethod
def get_type(path: Path) -> str:
"""
Method returns filetype string for file
:param path: Path of file to determine type
:return: filetype string: audio, image, video, unknown
"""
extensions = {
"audio": ['.aac', '.flac', '.m4a', '.mp3', '.ogg', '.opus', '.raw', '.wav', '.wma'],
"image": ['.apng', '.avif', '.bmp', '.tga', '.tiff', '.dds', '.svg', '.webp', '.jpg', '.jpeg', '.png'],
"video": ['.3gp' '.amv', '.avi', '.m2t', '.m4v', '.mkv', '.mov', '.mp4', '.m4v', '.mpeg', '.mpv',
'.webm', '.ogv']
}
for file_type in extensions:
if path.suffix in extensions[file_type]:
return file_type
return "unknown"
@staticmethod
def has_transparency(img: Image) -> bool:
"""
Method checks if image has transparency
:param img: Pillow Image
:return: bool
"""
if img.info.get("transparency", None) is not None:
return True
if img.mode == "P":
transparent = img.info.get("transparency", -1)
for _, index in img.getcolors():
if index == transparent:
return True
elif img.mode == "RGBA":
extrema = img.getextrema()
if extrema[3][0] < 255:
return True
return False
class Compress:
def __init__(self, params_inst: Params, printer_inst: Printer, utils_inst: Utils):
self.__params = params_inst
self.__printer = printer_inst
self.__utils = utils_inst
def audio(self, input_path: Path, output_dir: Path, extension: str) -> Path:
"""
Method recodes audio files to another format using ffmpeg utility
:param input_path: Path of the original audio file
:param output_dir: Path of the output (compression) folder
:param extension: Extension of the new audio file
:return: Path of compressed audio file with md5 hash as prefix
"""
bit_rate = self.__params.audio_bitrate
prefix = self.__utils.get_hash(input_path.name)
out_file = Path(output_dir, f'.{prefix}_{input_path.stem}.{extension}')
try:
(FFmpeg()
.input(input_path)
.option("hide_banner")
.output(out_file,{"b:a": bit_rate, "loglevel": "error"})
.execute()
)
except FFmpegError as e:
self.__utils.catch_unprocessed(input_path, out_file, e)
self.__printer.files(input_path, out_file, f"{bit_rate}")
return out_file
def image(self, input_path: Path, output_dir: Path, extension: str) -> Path:
"""
Method recodes image files to another format using Pillow
:param input_path: Path of the original image file
:param output_dir: Path of the output (compression) folder
:param extension: Extension of the new image file
:return: Path of compressed image file with md5 hash as prefix
"""
quality = self.__params.image_quality
prefix = self.__utils.get_hash(input_path.name)
out_file = Path(output_dir, f".{prefix}_{input_path.stem}.{extension}")
try:
image = Image.open(input_path)
if (extension == "jpg" or extension == "jpeg" or
(extension == "webp" and not self.__params.webp_rgba)):
if File.has_transparency(image):
self.__printer.warning(f"{input_path.name} has transparency. Changing to fallback...")
out_file = Path(output_dir, f".{prefix}_{input_path.stem}.{self.__params.image_fall_ext}")
if File.has_transparency(image):
image.convert('RGBA')
res_downscale = self.__params.image_downscale
if res_downscale != 1:
width, height = image.size
new_size = (int(width / res_downscale), int(height / res_downscale))
image = image.resize(new_size)
image.save(out_file,
optimize=True,
lossless=self.__params.image_lossless,
quality=quality,
minimize_size=True)
self.__printer.files(input_path, out_file, f"{quality}%")
except Exception as e:
self.__utils.catch_unprocessed(input_path, out_file, e)
return out_file
def video(self, input_path: Path, output_dir: Path, extension: str) -> Path:
"""
Method recodes video files to another format using ffmpeg utility
:param input_path: Path of the original video file
:param output_dir: Path of the output (compression) folder
:param extension: Extension of the new video file
:return: Path of compressed video file with md5 hash as prefix
"""
prefix = self.__utils.get_hash(input_path.name)
out_file = Path(output_dir, f'.{prefix}_{input_path.stem}.{extension}')
if not self.__params.video_skip:
codec = self.__params.video_codec
crf = self.__params.video_crf
try:
(FFmpeg()
.input(input_path)
.option("hide_banner")
.option("hwaccel", "auto")
.output(out_file,{"codec:v": codec, "v:b": 0, "loglevel": "error"}, crf=crf)
.execute()
)
self.__printer.files(input_path, out_file, codec)
except FFmpegError as e:
self.__utils.catch_unprocessed(input_path, out_file, e)
else:
self.__utils.copy_unprocessed(input_path, out_file)
return out_file
def unknown(self, input_path: Path, output_dir: Path) -> Path:
"""
Method recodes files with "unknown" file format using ffmpeg,
in the hope that ffmpeg supports this file type and the default settings for it will reduce its size
:param input_path: Path of the original file
:param output_dir: Path of the output (compression) folder
:return: Path of compressed file with md5 hash as prefix
"""
prefix = self.__utils.get_hash(input_path.name)
out_file = Path(output_dir, f".{prefix}_{input_path.name}")
if self.__params.force_compress:
self.__printer.unknown_file(input_path.name)
try:
(FFmpeg()
.input(input_path)
.output(out_file)
.execute()
)
except FFmpegError as e:
self.__utils.catch_unprocessed(input_path, out_file, e)
else:
self.__utils.copy_unprocessed(input_path, out_file)
return out_file
def compress(self, source: Path, output: Path):
"""
It the core method for this program. Method determines file type and call compress function for it
:param source: Path of file to compress
:param output: Path of output file
:return: None
"""
match File.get_type(source):
case "audio":
out_file = self.audio(source, output, self.__params.audio_ext)
case "image":
out_file = self.image(source, output, self.__params.image_ext)
case "video":
out_file = self.video(source, output, self.__params.video_ext)
case "unknown":
out_file = self.unknown(source, output)
self.__utils.out_rename(out_file, source)
self.__printer.bar.update()
self.__printer.bar.next()

112
vnrecode/params.py Normal file
View file

@ -0,0 +1,112 @@
from argparse import ArgumentParser, Namespace
from dataclasses import dataclass
from pathlib import Path
from typing import Self
import tomllib
@dataclass
class Params:
"""
This dataclass contains all parameters for utility
"""
copy_unprocessed: bool
force_compress: bool
mimic_mode: bool
hide_errors: bool
webp_rgba: bool
workers: int
audio_ext: str
audio_bitrate: str
image_downscale: int
image_ext: str
image_fall_ext: str
image_lossless: str
image_quality: int
video_crf: int
video_skip: bool
video_ext: str
video_codec: str
source: Path
dest: Path
@classmethod
def setup(cls) -> Self:
"""
Method initialize all parameters and returns class instance
:return: Params instance
"""
args = cls.get_args()
if args.config is not None:
if Path(args.config).is_file():
with open(args.config, "rb") as cfile:
config = tomllib.load(cfile)
else:
print("Failed to find config. Check `vnrecode -h` to more info")
exit(255)
copy_unprocessed = config["FFMPEG"]["CopyUnprocessed"] if args.config else args.unproc
force_compress = config["FFMPEG"]["ForceCompress"] if args.config else args.force
mimic_mode = config["FFMPEG"]["MimicMode"] if args.config else args.mimic
hide_errors = config["FFMPEG"]["HideErrors"] if args.config else args.show_errors
workers = config["FFMPEG"]["Workers"] if args.config else args.jobs
webp_rgba = config["FFMPEG"]["WebpRGBA"] if args.config else args.webp_rgba
audio_ext = config["AUDIO"]["Extension"] if args.config else args.a_ext
audio_bitrate = config["AUDIO"]["BitRate"] if args.config else args.a_bit
image_downscale = config["IMAGE"]["ResDownScale"] if args.config else args.i_down
image_ext = config["IMAGE"]["Extension"] if args.config else args.i_ext
image_fall_ext = config["IMAGE"]["FallBackExtension"] if args.config else args.i_fallext
image_lossless = config["IMAGE"]["Lossless"] if args.config else args.i_lossless
image_quality = config["IMAGE"]["Quality"] if args.config else args.i_quality
video_crf = config["VIDEO"]["CRF"] if args.config else args.v_crf
video_skip = config["VIDEO"]["SkipVideo"] if args.config else args.v_skip
video_ext = config["VIDEO"]["Extension"] if args.config else args.v_ext
video_codec = config["VIDEO"]["Codec"] if args.config else args.v_codec
source = Path(args.source)
if not source.exists():
print("Requested path does not exists. Exiting!")
exit(255)
dest = Path(source.parent, source.name + f"_compressed")
return cls(
copy_unprocessed, force_compress, mimic_mode, hide_errors, webp_rgba, workers,
audio_ext, audio_bitrate,
image_downscale, image_ext, image_fall_ext, image_lossless, image_quality,
video_crf, video_skip, video_ext, video_codec, source, dest
)
@staticmethod
def get_args() -> Namespace:
"""
Method gets CLI arguments and returns argparse.Namespace instance
:return: argparse.Namespace of CLI args
"""
parser = ArgumentParser(prog="vnrecode",
description="Python utility to compress Visual Novel Resources"
)
parser.add_argument("source", help="Directory with game files to recode")
parser.add_argument("-c", dest='config', help="Utility config file")
parser.add_argument("-nu", dest='unproc', action='store_false', help="Don't copy unprocessed")
parser.add_argument("-f", "--force", action='store_true', help="Try to recode unknown files")
parser.add_argument("-nm", "--no-mimic", dest='mimic', action='store_false', help="Disable mimic mode")
parser.add_argument("-v", "--show_errors", action='store_false', help="Show recode errors")
parser.add_argument("--webp-rgb", dest='webp_rgba', action='store_false', help="Recode .webp without alpha channel")
parser.add_argument("-j", "--jobs", type=int, help="Number of threads (default: 16)", default=16)
parser.add_argument("-ae", dest="a_ext", help="Audio extension (default: opus)", default="opus")
parser.add_argument("-ab", dest="a_bit", help="Audio bit rate (default: 128k)", default="128k")
parser.add_argument("-id", dest="i_down", type=float, help="Image resolution downscale multiplier (default: 1)", default=1)
parser.add_argument("-ie", dest="i_ext", help="Image extension (default: avif)", default="avif")
parser.add_argument("-ife", dest="i_fallext", help="Image fallback extension (default: webp)", default="webp")
parser.add_argument("-il", dest='i_lossless', action='store_false', help="Image losing compression mode")
parser.add_argument("-iq", dest="i_quality", type=int, help="Image quality (default: 100)", default=100)
parser.add_argument("--v_crf", help="Video CRF number (default: 27)", type=int, default=27)
parser.add_argument("-vs", dest="v_skip", action='store_true', help="Skip video recoding")
parser.add_argument("-ve", dest="v_ext", help="Video extension (default: webm)", default="webm")
parser.add_argument("-vc", dest="v_codec", help="Video codec name (default: libvpx-vp9)", default="libvpx-vp9")
args = parser.parse_args()
return args

82
vnrecode/printer.py Normal file
View file

@ -0,0 +1,82 @@
from progress.bar import IncrementalBar
from pathlib import Path
import colorama
import sys
import os
class Printer:
"""
Class implements CLI UI for this utility
"""
def __init__(self, source: Path):
"""
:param source: Path of original (compressing) folder to count its files for progress bar
"""
file_count = 0
for folder, folders, file in os.walk(source):
file_count += len(file)
self.bar = IncrementalBar('Recoding', max=file_count, suffix='[%(index)d/%(max)d] (%(percent).1f%%)')
self.bar.update()
@staticmethod
def win_ascii_esc():
"""
Method setups colorama for cmd
:return: None
"""
if sys.platform == "win32":
colorama.init()
def bar_print(self, string: str):
"""
Method prints some string in console and updates progress bar
:param string: String to print
:return: None
"""
print(string)
self.bar.update()
def info(self, string: str):
"""
Method prints string with decor for info messages
:param string: String to print
:return: None
"""
self.bar_print(f"\x1b[2K\r\033[100m- {string}\033[49m")
def warning(self, string: str):
"""
Method prints string with decor for warning messages
:param string: String to print
:return: None
"""
self.bar_print(f"\x1b[2K\r\033[93m!\033[0m {string}\033[49m")
def error(self, string: str):
"""
Method prints string with decor for error messages
:param string: String to print
:return: None
"""
self.bar_print(f"\x1b[2K\r\033[31m\u2715\033[0m {string}\033[49m")
def files(self, source_path: Path, output_path: Path, comment: str):
"""
Method prints the result of recoding a file with some decorations in the form:
input file name -> output file name (quality setting)
:param source_path: Input file Path
:param output_path: Output file Path
:param comment: Comment about recode quality setting
:return: None
"""
self.bar_print(f"\x1b[2K\r\033[0;32m\u2713\033[0m \033[0;37m{source_path.stem}\033[0m{source_path.suffix}\033[0;37m -> "
f"{source_path.stem}\033[0m{output_path.suffix}\033[0;37m ({comment})\033[0m")
def unknown_file(self, filename: str):
"""
Method prints the result of recoding unknown file
:param filename: Name of unknown file
:return:
"""
self.bar_print(f"\x1b[2K\r\u2713 \033[0;33m{filename}\033[0m (File will be force compressed via ffmpeg)")

144
vnrecode/utils.py Normal file
View file

@ -0,0 +1,144 @@
from shutil import copyfile
from pathlib import Path
import hashlib
import sys
import os
from vnrecode.printer import Printer
from vnrecode.params import Params
class Utils:
"""
Class contains various methods for internal utility use
"""
def __init__(self, params_inst: Params, printer_inst: Printer):
self.__errors = 0
self.__params = params_inst
self.__printer = printer_inst
self.__duplicates = {}
@staticmethod
def sys_pause():
"""
Method calls pause for Windows cmd shell
:return: None
"""
if sys.platform == "win32":
os.system("pause")
@staticmethod
def get_hash(filename: str) -> str:
"""
Method returns 8 chars of md5 hash for filename
:param filename: File name to get md5
:return: 8 chars of md5 hash
"""
return hashlib.md5(filename.encode()).hexdigest()[:8]
def get_comp_subdir(self, folder: str) -> Path:
"""
Method returns the Path from str, changing the source folder in it to a compressed one
:param folder: source subfolder
:return: Path object with compressed subfolder
"""
return Path(folder.replace(str(self.__params.source), str(self.__params.dest), 1))
def get_recode_status(self):
"""
Method prints recoding results
:return: None
"""
source_len = 0
output_len = 0
for folder, folders, files in os.walk(self.__params.source):
source_len += len(files)
for folder, folders, files in os.walk(self.__params.dest):
for file in files:
if not file.count("(vncopy)"):
output_len += 1
if self.__errors != 0:
self.__printer.warning("Some files failed to compress!")
if source_len == output_len:
self.__printer.info("Success!")
else:
self.__printer.warning("Original and compressed folders are not identical!")
try:
source = sum(file.stat().st_size for file in self.__params.source.glob('**/*') if file.is_file())
output = sum(file.stat().st_size for file in self.__params.dest.glob('**/*') if file.is_file())
print(f"\nResult: {source/1024/1024:.2f}MB -> "
f"{output/1024/1024:.2f}MB ({(output - source)/1024/1024:.2f}MB)")
except ZeroDivisionError:
self.__printer.warning("Nothing compressed!")
def catch_unprocessed(self, input_path: Path, output_path: Path, error):
"""
Method processes files that have not been recoded due to an error and prints error to console
if hide_errors parameter is False
:param input_path: Path of unprocessed file
:param output_path: Destination path of unprocessed file
:param error: Recoding exception
:return: None
"""
self.copy_unprocessed(input_path, output_path)
self.__errors += 1
if not self.__params.hide_errors:
self.__printer.error(f"File {input_path.name} can't be processed! Error: {error}")
def copy_unprocessed(self, input_path: Path, output_path: Path):
"""
Method copies an unprocessed file from the source folder to the destination folder
:param input_path: Path of unprocessed file
:param output_path: Destination path of unprocessed file
:return: None
"""
if self.__params.copy_unprocessed:
copyfile(input_path, output_path)
self.__printer.info(f"File {input_path.name} copied to compressed folder.")
def catch_duplicates(self, path: Path) -> Path:
"""
Method checks if file path exists and returns folder/filename(vncopy).ext path
if duplicate founded
:param path: Some file Path
:return: Duplicate path name with (vncopy) on end
"""
if path.is_file() and path.exists():
orig_name = path.name.replace("(vncopy)", "")
new_path = Path(path.parent, path.stem + "(vncopy)" + path.suffix)
try: self.__duplicates[orig_name]
except KeyError: self.__duplicates[orig_name] = []
if not new_path.name in self.__duplicates[orig_name]:
self.__duplicates[orig_name].append(new_path.name)
return self.catch_duplicates(new_path)
return path
def print_duplicates(self):
"""
Method prints message about all duplicates generated during recode process
:return: None
"""
for filename in self.__duplicates.keys():
self.__printer.warning(
f'Duplicate file has been found! Check manually this files - "{filename}", ' +
', '.join(self.__duplicates[filename])
)
def out_rename(self, out_path: Path, target: Path):
"""
Method removes md5 hash from file name and changes file extension in dependence of mimic mode
:param out_path: Recoded file Path
:param target: Target filename
:return: None
"""
if not self.__params.mimic_mode:
dest_name = self.catch_duplicates(Path(out_path.parent, target.stem+out_path.suffix))
os.rename(out_path, dest_name)
else:
os.rename(out_path, Path(out_path.parent, target.name))

24
vnrecode/vnrecode.toml Normal file
View file

@ -0,0 +1,24 @@
[FFMPEG]
CopyUnprocessed = true
ForceCompress = false
MimicMode = true
HideErrors = true
WebpRGBA = true
Workers = 16
[AUDIO]
Extension = "opus"
BitRate = "128k"
[IMAGE]
ResDownScale = 1
Extension = "avif"
FallBackExtension = "webp"
Lossless = true
Quality = 100
[VIDEO]
CRF = 27
SkipVideo = false
Extension = "webm"
Codec = "libvpx-vp9"