Compare commits

...

106 commits

Author SHA1 Message Date
9f903caa28 Merge pull request 'Version 2.0 from testing branch' (#1) from testing into master
Reviewed-on: #1
2025-02-18 23:38:04 +03:00
3f3de59844 vnrecode: make temp files hidden 2025-02-07 02:24:34 +03:00
00925a4908 vnrecode: float image downscale multiplier 2025-02-07 02:19:54 +03:00
555ea0ebbb Initial Arch Linux packaging 2025-02-01 15:45:14 +03:00
e5bf961ddb vnrecode: fix dest path relativity 2025-01-28 21:17:57 +03:00
debc1755bb vnrecode: add info about default values 2025-01-24 03:22:27 +03:00
ce62218cda vnrecode: change progress bar message 2025-01-24 02:31:26 +03:00
3802c52ba0 vnrecode: check if source path exist (lol) 2025-01-24 02:30:55 +03:00
9c367e5249 vnrecode: fix dest path name 2024-12-28 03:12:45 +03:00
c1f152d8d0 Remove strict dependencies versioning 2024-12-28 02:09:56 +03:00
85f1c3776f Fix compiled binaries launch 2024-11-08 05:10:40 +03:00
d4092b46df vnrecode: replace funky method with ASCII symbol 2024-11-08 04:33:05 +03:00
626eaae5e2 vnrecode: fix renaming for same name subfolders 2024-11-01 03:23:50 +03:00
bc84703b73 vnrecode: fix typo in input folder name 2024-10-19 02:51:57 +03:00
a75314d2ad vnrecode: ignore ansi escapes for string cleaning 2024-10-19 02:44:51 +03:00
407ab98000 vnrecode: duplicates check for more than two files 2024-10-19 02:27:07 +03:00
df20bd3636 vnrecode: docstrings 2024-10-19 01:45:35 +03:00
1c1e8a9292 vnrecode: pathlib for all paths 2024-10-19 00:25:52 +03:00
4e6fd332c5 vnrecode: rewrite duplicates processing 2024-10-18 22:54:49 +03:00
9bb3cdcccb vnrecode: make it private! 2024-10-18 22:54:49 +03:00
d8e55bac9a Update all README.md files 2024-10-13 22:28:14 +03:00
0b43756ef5 vnrecode: fix duplicates check for case insensitive fs 2024-09-04 04:30:24 +03:00
90a6b4e0c1 vnrecode: add re to duplicate check 2024-09-04 03:25:43 +03:00
7433027cf3 vnrecode: add type definitions 2024-09-04 02:40:47 +03:00
8f9db132e6 vnrecode: remove unneeded cli parameters 2024-09-04 02:13:34 +03:00
b534214be9 vnrecode: accurate duplicate messages 2024-09-04 02:07:17 +03:00
a9aeb52506 vnrecode: improve cli parameters 2024-09-04 01:54:06 +03:00
44c12a5688 Update requirements 2024-09-04 01:22:42 +03:00
92474b4aa4 vnrecode: improve duplications check for mt 2024-09-04 01:21:59 +03:00
03647d4b84 vnrecode: rewrite get_type method 2024-09-04 00:57:15 +03:00
a69b17c624 vnrecode: improve naming, fix mimic mode errors 2024-09-03 22:54:59 +03:00
71c5764f26 vnrecode: replace config class to params 2024-09-03 22:44:58 +03:00
f240fdca5f vnrecode: add cli parameters for configuration 2024-08-29 04:23:00 +03:00
e5fa49ad53 Fix packaging names 2024-08-29 03:00:09 +03:00
85df574d3c Python packaging 2024-08-29 02:34:10 +03:00
7487eb94bd Basic refactor for packaging 2024-08-29 01:32:36 +03:00
0a9114ff64 FFMpeg-Compressor: Show only completed tasks 2024-07-19 03:02:38 +03:00
31e82b59b3 FFMpeg-Compressor: Edit result message 2024-07-19 02:36:00 +03:00
dff5bd12f1 FFMpeg-Compressor: Remove .gif from video section 2024-07-19 02:22:38 +03:00
23f7e5ec67 FFMpeg-Compressor: Implement multiple FIFO workers 2024-07-19 02:17:04 +03:00
54820279d1 FFMpeg-Compressor: Enable hardware video decoding 2024-06-15 03:34:19 +03:00
da64641bed FFMpeg-Compressor: Do not convert not transparent to RGBA 2024-06-15 02:56:27 +03:00
82ac0a1301 FFMpeg-Compressor: Fix transparency decoding 2024-05-16 07:54:20 +03:00
df1122bcd0 RenPy-Unpacker: Add MacOS support 2024-05-09 00:18:57 +03:00
ad6ea5aa68 FFMpeg-Compressor: Fix unicode data decoding in metadata 2024-05-06 23:13:17 +03:00
e71cad4d5f FFMpeg-Compressor: Refactor and bar fix 2024-05-05 03:14:27 +03:00
9612d30d06 FFMpeg-Compressor: Add avif transparency support 2024-05-05 01:12:29 +03:00
40c2683132 FFMpeg-Compressor: Add time taken message 2024-05-05 01:11:58 +03:00
adb8c83840 FFMpeg-Compressor: Don't use resize if downscale is 1 2024-05-05 01:06:10 +03:00
bf7c97d125 Add venv creation message 2024-05-04 23:51:40 +03:00
0ad60b5b94 FFMpeg-Compressor: Switch to other ffmpeg lib, add CRF parameter for video 2024-04-26 01:33:51 +03:00
a973fe79e8 FFMpeg-Compressor: Add SkipVideo parameter 2024-04-22 23:23:06 +03:00
f9f6afe95a FFMpeg-Compressor: Add resolution scaledown 2024-04-12 17:19:53 +03:00
7c355e38f7 FFMpeg-Compressor: New config default parameters 2024-02-23 00:23:16 +03:00
f39c28168b Multiplatform shebangs 2024-02-03 23:58:40 +03:00
01d99b818f Update build script 2024-02-03 23:54:50 +03:00
dbf627d15e RenPy-Android-Unpack: Newest Ren'Py support 2024-02-03 23:39:00 +03:00
20f86f7c0e RenPy-Android-Unpack: Some improvements 2024-02-03 22:45:47 +03:00
248f08c7d9 FFMpeg-Compressor: Add ForceCompress parameter 2024-01-15 22:20:23 +03:00
c1b57bddf2 Fix typo 2024-01-15 20:59:38 +03:00
775b5c539f Compile VNDS-to-RenPy binary 2024-01-13 00:04:50 +03:00
7ed04ffd22 FFMpeg-Compressor: Windows cmd support 2024-01-13 00:04:50 +03:00
dc858b4ebb Overwrite binaries in output folder 2024-01-13 00:04:50 +03:00
b9c7b512de Exit on build error 2024-01-13 00:04:50 +03:00
9b98fa72f9 FFMpeg-Compressor: Update README.md 2024-01-13 00:04:50 +03:00
ee88780a9f Add Windows build script, improve build scripts 2024-01-13 00:04:50 +03:00
07e9b09b5c FFMpeg-Compressor: Fix main.py running 2024-01-13 00:04:50 +03:00
e4f028f75b VNDS-to-RenPy: Add README.md 2024-01-13 00:04:49 +03:00
7bcb74b70f Add VNDS-to-RenPy script 2024-01-12 18:11:31 +03:00
27efc155b9 FFMpeg-Compressor: Copy unprocessed files while running 2024-01-12 18:11:12 +03:00
6698db5fef FFMpeg-Compressor: Fix size displaying 2023-11-28 23:36:01 +03:00
fd7b9cd4d9 FFMpeg-Compressor: Fix codec in video compression 2023-11-28 22:00:08 +03:00
e70dd56142 FFMpeg-Compressor: FallBackExtension parameter 2023-11-28 19:07:09 +03:00
2f31c22d91 FFMpeg-Compressor: Fix some logic 2023-11-28 17:10:22 +03:00
e34b06b507 FFMpeg-Compressor: Fix comp size regression 2023-11-28 17:08:17 +03:00
4b050a5659 FFMpeg-Compressor: Add lossless parameter 2023-11-16 14:27:50 +03:00
f7ecee5683 FFMpeg-Compressor: Update README.md 2023-11-16 02:36:06 +03:00
721d33efa7 FFMpeg-Compressor: Slightly rewrite interface 2023-11-16 02:23:37 +03:00
37ff1f78b3 FFMpeg-Compressor: Use ffmpeg-python and Pillow instead of ffmpeg cli 2023-11-16 02:23:37 +03:00
e48b7599d1 FFMpeg-Compressor: Support .m2t file format 2023-10-30 00:56:53 +03:00
37e3c9b257 RenPy-Android-Unpack: Execute python3 from PATH 2023-10-11 18:56:00 +03:00
7dee19ae08 FFMpeg-Compressor: Revert enabling hwaccel by default 2023-10-11 18:54:45 +03:00
603032fe78 FFMpeg-Compressor: Fix recursive unprocessed files copying 2023-10-11 18:54:44 +03:00
616e7f63ef FFMpeg-Compressor: Use ffmpeg hwaccel if available 2023-10-09 23:56:52 +03:00
12b7fced03 FFMpeg-Compressor: Fix typo 2023-10-09 23:55:10 +03:00
ea893fd8d1 FFMpeg-Compressor: Delete JpegCompression parameter in config 2023-10-09 23:47:16 +03:00
7d087fc5b6 FFMpeg-Compressor: Option to disabling WebP RGBA 2023-10-09 23:41:19 +03:00
224359f251 FFMpeg-Compressor: Fix errors hiding 2023-10-08 21:05:27 +03:00
0a7e62477a FFMpeg-Compressor: Fix for duplicated file algorithm 2023-10-07 13:27:48 +03:00
d3c8b43b49 FFMpeg-Compressor: Add new missing parameter into config 2023-10-07 01:03:48 +03:00
e9777b8779 Determining logic cpu count in Darwin 2023-10-07 01:01:11 +03:00
329196a376 FFMpeg-Compressor: New HideErrors parameter 2023-10-07 00:52:21 +03:00
70e3254395 FFMpeg-Compressor: Fix Mimic mode with subdirs 2023-10-07 00:06:51 +03:00
04cc015909 Update README.md 2023-10-02 20:05:11 +03:00
c36a403a1c FFMpeg-Compressor: Add .bmp support 2023-10-02 19:50:36 +03:00
e3b21e7d81 FFMpeg-Compressor: Update README.md 2023-10-02 19:48:45 +03:00
f15bb3df7e FFMpeg-Compressor: Hide all ffmpeg warnings and errors 2023-10-02 19:46:12 +03:00
cefd0bc9ba FFMpeg-Compressor: Copy all unprocessed files to destination folder 2023-10-02 19:23:11 +03:00
e4de881424 FFMpeg-Compressor: Skip all ffmpeg questions by default 2023-10-02 17:39:32 +03:00
73fd5e44d1 RenPy-Unpacker: Update README.md 2023-10-01 23:46:51 +03:00
44c2b1a44b RenPy-Android-Unpack: Update README.md 2023-10-01 23:46:51 +03:00
875df526ad FFMpeg-Compressor: TODO in README.md 2023-10-01 23:46:51 +03:00
06116b5de1 FFMpeg-Compressor: Add Configuration section in README.md 2023-10-01 23:46:51 +03:00
4b76a600fb Add installing Nuitka in build script 2023-10-01 23:46:50 +03:00
d57585f5a2 FFMpeg-Compressor: Implement mimic mode 2023-10-01 22:46:16 +03:00
657aa31148 FFMpeg-Compressor: Rewrite some code 2023-09-30 23:51:53 +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 ## VNTools
Collection of tools used by administrators from VN Telegram Channel Collection of tools used by VienDesu! Porting Team
### Tools ### Tools
* `FFMpeg-Compressor` - Python utility uses ffmpeg to compress Visual Novel Resources * `vnrecode` - Python utility 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 * `unrenapk` - A Python script for extracting game project from Ren'Py based .apk and .obb files
* `RenPy-Unpacker` - Simple .rpy script that will make any RenPy game unpack itself * `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 #!/usr/bin/env bash
mkdir output set -e
mkdir output/bin if [[ "$VIRTUAL_ENV" == "" ]]
nuitka3 --jobs=$(nproc) --output-dir=output --follow-imports --output-filename=output/bin/ffmpeg-comp FFMpeg-Compressor/main.py then
cp FFMpeg-Compressor/ffmpeg-comp.toml output/bin/ echo -e "Please create and activate venv before running this script: \033[100mpython3 -m venv venv && source ./venv/bin/activate\033[49m"
nuitka3 --jobs=$(nproc) --output-dir=output --follow-imports --output-filename=output/bin/rendroid-unpack RenPy-Android-Unpack/unpack.py 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"