wesnoth/utils/optiwebp.py
Dalas121 6765db5431
DM -> TDG translations cleanup (#9779)
Various minor changes to tools to better-support the addition of TDG.
2025-02-09 07:48:36 -06:00

199 lines
9.9 KiB
Python
Executable File

#!/usr/bin/env python3
##
# This script uses cwebp to convert existing png/jpg images to webp, or reconvert existing webp images, to see if doing so meaningfully reduces their size.
##
import argparse
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
def signal_handler(signal, func):
print("\nProcessing interrupted.")
shutil.rmtree(options.tempdir)
sys.exit(1)
signal.signal(signal.SIGINT, signal_handler)
args = argparse.ArgumentParser()
args.add_argument("--minpercent", default=90, help="The new file size must be reduced by to least this percentage compared to the original. Default 90%% (10%% reduction).")
args.add_argument("--nodryrun", action="store_true", help="Whether to replace the original file with the converted one if its size is reduced enough. Does not replace by default.")
args.add_argument("--quality", default="90", help="The quality to use for lossy compression. Defaults to 90.")
args.add_argument("--repo", default=os.getcwd(), help="The root of the Wesnoth repository. Defaults to the current directory.")
args.add_argument("--tempdir", default=os.path.join(os.getcwd(), "temp_webp"), help="The root of the Wesnoth repository. Defaults to 'temp_webp/' in the current directory.")
options = args.parse_args()
# test we're in a repo root
if not os.path.isfile(os.path.join(options.repo, "changelog.md")):
print("Didn't find changelog.md so assuming this isn't the repo root, exiting.")
sys.exit(1)
# test cwebp presence
if subprocess.run(["cwebp", "-version"], stdout=subprocess.DEVNULL).returncode != 0:
print("cwebp executable not found in PATH, exiting.")
sys.exit(1)
image_dirs = [
os.path.join("data", "campaigns", "Dead_Water", "images", "maps"),
os.path.join("data", "campaigns", "Dead_Water", "images", "portraits"),
os.path.join("data", "campaigns", "Descent_Into_Darkness", "images", "maps"),
os.path.join("data", "campaigns", "Descent_Into_Darkness", "images", "portraits"),
os.path.join("data", "campaigns", "Descent_Into_Darkness", "images", "story"),
os.path.join("data", "campaigns", "Eastern_Invasion", "images", "portraits"),
os.path.join("data", "campaigns", "Eastern_Invasion", "images", "maps"),
os.path.join("data", "campaigns", "Heir_To_The_Throne", "images", "portraits"),
os.path.join("data", "campaigns", "Heir_To_The_Throne", "images", "maps"),
os.path.join("data", "campaigns", "Heir_To_The_Throne", "images", "story"),
os.path.join("data", "campaigns", "Legend_of_Wesmere", "images", "story"),
os.path.join("data", "campaigns", "Legend_of_Wesmere", "images", "portraits"),
os.path.join("data", "campaigns", "Legend_of_Wesmere", "images", "l10n"),
os.path.join("data", "campaigns", "Liberty", "images", "portraits"),
os.path.join("data", "campaigns", "Liberty", "images", "maps"),
os.path.join("data", "campaigns", "Liberty", "images", "story"),
os.path.join("data", "campaigns", "Northern_Rebirth", "images", "maps"),
os.path.join("data", "campaigns", "Northern_Rebirth", "images", "portraits"),
os.path.join("data", "campaigns", "Sceptre_of_Fire", "images", "maps"),
os.path.join("data", "campaigns", "Sceptre_of_Fire", "images", "portraits"),
os.path.join("data", "campaigns", "Secrets_of_the_Ancients", "images", "story"),
os.path.join("data", "campaigns", "Secrets_of_the_Ancients", "images", "portraits"),
os.path.join("data", "campaigns", "Son_Of_The_Black_Eye", "images", "maps"),
os.path.join("data", "campaigns", "Son_Of_The_Black_Eye", "images", "portraits"),
os.path.join("data", "campaigns", "The_Deceivers_Gambit", "images", "story"),
os.path.join("data", "campaigns", "The_Deceivers_Gambit", "images", "portraits"),
os.path.join("data", "campaigns", "The_Hammer_of_Thursagan", "images", "maps"),
os.path.join("data", "campaigns", "The_Hammer_of_Thursagan", "images", "portraits"),
os.path.join("data", "campaigns", "The_Rise_Of_Wesnoth", "images", "maps"),
os.path.join("data", "campaigns", "The_Rise_Of_Wesnoth", "images", "portraits"),
os.path.join("data", "campaigns", "The_Rise_Of_Wesnoth", "images", "story"),
os.path.join("data", "campaigns", "The_South_Guard", "images", "portraits"),
os.path.join("data", "campaigns", "The_South_Guard", "images", "maps"),
os.path.join("data", "campaigns", "The_South_Guard", "images", "story"),
os.path.join("data", "campaigns", "tutorial", "images", "portraits"),
os.path.join("data", "campaigns", "Two_Brothers", "images", "maps"),
os.path.join("data", "campaigns", "Two_Brothers", "images", "portraits"),
os.path.join("data", "campaigns", "Two_Brothers", "images", "story"),
os.path.join("data", "campaigns", "Under_the_Burning_Suns", "images", "portraits"),
os.path.join("data", "campaigns", "Winds_of_Fate", "images", "portraits"),
os.path.join("data", "campaigns", "Winds_of_Fate", "images", "maps"),
os.path.join("data", "campaigns", "Winds_of_Fate", "images", "story"),
os.path.join("data", "core", "images", "maps"),
os.path.join("data", "core", "images", "story"),
os.path.join("data", "core", "images", "portraits")
]
results_dict = {}
lossy_args = ["cwebp", "-mt", "-m", "6", "-q", options.quality, "-alpha_q", "100"]
lossless_args = ["cwebp", "-mt", "-z", "9", "-lossless", "-exact"]
os.makedirs(options.tempdir, exist_ok=True)
start = time.time()
count = 0
for image_dir in image_dirs:
print("Processing "+image_dir)
for image_root, _, files in os.walk(os.path.join(options.repo, image_dir)):
for filename in files:
count += 1
if count % 100 == 0:
print("Processing file {count}".format(count=count))
filetype = Path(filename).suffix
subprocess_args = []
if filetype == ".png":
subprocess_args = lossless_args
elif filetype == ".webp":
with open(os.path.join(image_root, filename), "rb") as img:
# discard first 12 bytes of WebP "magic number" and file size
img.read(12)
# read type of WebP file
# VP8L = lossless
# VP8 = lossy, so skip: lossy -> lossy is bad
# VP8X = can either, so assume lossless (better to try to go from lossy->lossless than the reverse)
webp_type = img.read(4)
if webp_type == b"VP8L" or webp_type == b"VP8X":
subprocess_args = lossless_args
elif webp_type == b"VP8 ":
continue
else:
print(filename, "is not a valid WebP file", file=sys.stderr)
continue
elif filetype in (".jpg", ".jpeg"):
subprocess_args = lossy_args
else:
continue
initial_file = os.path.join(image_root, filename)
webp_file = ""
if filetype in (".jpg", ".png"):
webp_file = os.path.join(options.tempdir, filename)[:-3]+"webp"
elif filetype == ".jpeg":
webp_file = os.path.join(options.tempdir, filename)[:-4]+"webp"
else:
webp_file = os.path.join(options.tempdir, filename)
result = subprocess.run(subprocess_args + [initial_file, "-o", webp_file], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode != 0:
print(initial_file+" failed conversion to webp.")
continue
initial_size = os.path.getsize(initial_file)
converted_size = os.path.getsize(webp_file)
percentage_change = round((converted_size/initial_size)*100, 2)
absolute_change = initial_size - converted_size
results_dict[initial_file] = {
"type": filetype,
"new_file": webp_file,
"old_size": initial_size,
"new_size": converted_size,
"change_in_bytes": absolute_change,
"change_in_percent": percentage_change
}
if options.nodryrun and percentage_change < options.minpercent:
os.remove(initial_file)
shutil.copy(webp_file, image_root)
os.remove(webp_file)
initial_total_size = 0
final_total_size = 0
try:
with open("conversion-good.tsv", "w") as good_f, open("conversion-bad.tsv", "w") as bad_f:
good_f.write("filename\told_size\tnew_size\tchange_in_percent\tchange_in_bytes\n")
bad_f.write("filename\told_size\tnew_size\tchange_in_percent\tchange_in_bytes\n")
for filename, data in results_dict.items():
line = "{filename}\t{old_size}\t{new_size}\t{change_in_percent}\t{change_in_bytes}\n".format(filename=filename, old_size=data["old_size"], new_size=data["new_size"], change_in_percent=data["change_in_percent"], change_in_bytes=data["change_in_bytes"])
if data["change_in_percent"] < options.minpercent:
initial_total_size = initial_total_size + data["old_size"]
final_total_size = final_total_size + data["new_size"]
good_f.write(line)
else:
bad_f.write(line)
except OSError:
print("Error writing summary output files!")
sys.exit(1)
total_percentage_change = round((final_total_size/initial_total_size)*100, 2)
duration = time.time() - start
hours_duration = int(duration / 3600)
minutes_duration = int((duration - (hours_duration * 3600)) / 60)
seconds_duration = int(duration % 60)
os.rmdir(options.tempdir)
print("Total size for converted images changed from {initial} to {final} ({percentage}%)".format(initial=initial_total_size, final=final_total_size, percentage=total_percentage_change))
print("Took {hours:02d}:{minutes:02d}:{seconds:02d} to process {count} files.".format(hours=hours_duration, minutes=minutes_duration, seconds=seconds_duration, count=len(results_dict)))