Compare commits
106 commits
a7ed37d58c
...
9f903caa28
Author | SHA1 | Date | |
---|---|---|---|
9f903caa28 | |||
3f3de59844 | |||
00925a4908 | |||
555ea0ebbb | |||
e5bf961ddb | |||
debc1755bb | |||
ce62218cda | |||
3802c52ba0 | |||
9c367e5249 | |||
c1f152d8d0 | |||
85f1c3776f | |||
d4092b46df | |||
626eaae5e2 | |||
bc84703b73 | |||
a75314d2ad | |||
407ab98000 | |||
df20bd3636 | |||
1c1e8a9292 | |||
4e6fd332c5 | |||
9bb3cdcccb | |||
d8e55bac9a | |||
0b43756ef5 | |||
90a6b4e0c1 | |||
7433027cf3 | |||
8f9db132e6 | |||
b534214be9 | |||
a9aeb52506 | |||
44c12a5688 | |||
92474b4aa4 | |||
03647d4b84 | |||
a69b17c624 | |||
71c5764f26 | |||
f240fdca5f | |||
e5fa49ad53 | |||
85df574d3c | |||
7487eb94bd | |||
0a9114ff64 | |||
31e82b59b3 | |||
dff5bd12f1 | |||
23f7e5ec67 | |||
54820279d1 | |||
da64641bed | |||
82ac0a1301 | |||
df1122bcd0 | |||
ad6ea5aa68 | |||
e71cad4d5f | |||
9612d30d06 | |||
40c2683132 | |||
adb8c83840 | |||
bf7c97d125 | |||
0ad60b5b94 | |||
a973fe79e8 | |||
f9f6afe95a | |||
7c355e38f7 | |||
f39c28168b | |||
01d99b818f | |||
dbf627d15e | |||
20f86f7c0e | |||
248f08c7d9 | |||
c1b57bddf2 | |||
775b5c539f | |||
7ed04ffd22 | |||
dc858b4ebb | |||
b9c7b512de | |||
9b98fa72f9 | |||
ee88780a9f | |||
07e9b09b5c | |||
e4f028f75b | |||
7bcb74b70f | |||
27efc155b9 | |||
6698db5fef | |||
fd7b9cd4d9 | |||
e70dd56142 | |||
2f31c22d91 | |||
e34b06b507 | |||
4b050a5659 | |||
f7ecee5683 | |||
721d33efa7 | |||
37ff1f78b3 | |||
e48b7599d1 | |||
37e3c9b257 | |||
7dee19ae08 | |||
603032fe78 | |||
616e7f63ef | |||
12b7fced03 | |||
ea893fd8d1 | |||
7d087fc5b6 | |||
224359f251 | |||
0a7e62477a | |||
d3c8b43b49 | |||
e9777b8779 | |||
329196a376 | |||
70e3254395 | |||
04cc015909 | |||
c36a403a1c | |||
e3b21e7d81 | |||
f15bb3df7e | |||
cefd0bc9ba | |||
e4de881424 | |||
73fd5e44d1 | |||
44c2b1a44b | |||
875df526ad | |||
06116b5de1 | |||
4b76a600fb | |||
d57585f5a2 | |||
657aa31148 |
38 changed files with 1402 additions and 334 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/output/
|
||||||
|
/tests/
|
||||||
|
/tests_compressed/
|
||||||
|
/build/
|
||||||
|
/dist/
|
||||||
|
/vntools.egg-info/
|
|
@ -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}`
|
|
|
@ -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"
|
|
|
@ -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)
|
|
|
@ -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}'")
|
|
|
@ -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()
|
|
|
@ -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
|
|
|
@ -1,2 +0,0 @@
|
||||||
Pillow==9.5.0
|
|
||||||
progress==1.6
|
|
31
PKGBUILD
Normal file
31
PKGBUILD
Normal 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
|
||||||
|
}
|
||||||
|
|
24
README.md
24
README.md
|
@ -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`
|
||||||
|
|
|
@ -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`
|
|
|
@ -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')
|
|
|
@ -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!
|
|
|
@ -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
23
build.bat
Executable 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%
|
30
build.sh
30
build.sh
|
@ -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
32
pyproject.toml
Normal 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
10
renpy-ripper/README.md
Normal 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
17
renpy-ripper/ripper.rpy
Normal 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
6
requirements.txt
Normal 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
6
unrenapk/README.md
Normal 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
0
unrenapk/__init__.py
Normal file
6
unrenapk/__main__.py
Executable file
6
unrenapk/__main__.py
Executable file
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
from unrenapk import application
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
application.launch()
|
98
unrenapk/actions.py
Executable file
98
unrenapk/actions.py
Executable 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
51
unrenapk/application.py
Normal 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
14
unrenapk/printer.py
Normal 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
7
vnds2renpy/README.md
Normal 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
0
vnds2renpy/__init__.py
Normal file
351
vnds2renpy/__main__.py
Normal file
351
vnds2renpy/__main__.py
Normal 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
59
vnrecode/README.md
Normal 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
0
vnrecode/__init__.py
Normal file
23
vnrecode/__main__.py
Normal file
23
vnrecode/__main__.py
Normal 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
59
vnrecode/application.py
Executable 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
197
vnrecode/compress.py
Normal 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
112
vnrecode/params.py
Normal 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
82
vnrecode/printer.py
Normal 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
144
vnrecode/utils.py
Normal 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
24
vnrecode/vnrecode.toml
Normal 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"
|
Loading…
Add table
Add a link
Reference in a new issue