From a5a39011510852f95f01b0abfd9f0206b438ea8f Mon Sep 17 00:00:00 2001 From: MetroWind Date: Sun, 2 Nov 2025 14:12:20 -0800 Subject: Add ffmagick --- ffmagick/ffmagick.py | 210 +++++++++++++++++++++++++++++++++++++++++++++++++ ffmagick/ffmagick.toml | 17 ++++ 2 files changed, 227 insertions(+) create mode 100644 ffmagick/ffmagick.py create mode 100644 ffmagick/ffmagick.toml (limited to 'ffmagick') diff --git a/ffmagick/ffmagick.py b/ffmagick/ffmagick.py new file mode 100644 index 0000000..9bc01d3 --- /dev/null +++ b/ffmagick/ffmagick.py @@ -0,0 +1,210 @@ +#!/usr/bin/python + +import sys, os +import argparse +import subprocess +from enum import Enum +import tomllib as toml + +# Simple progress display, in a similar interface to the Progress library. +class ProgressBar: + def __init__(self, text, max): + self.max = max + self.text = text + self.current = 0 + + def next(self): + self.current += 1 + print("\r{} {}/{}".format(self.text, self.current, self.max), flush=True, + end="") + + def finish(self): + print(flush=True) + +try: + from progress.bar import Bar as Progress + HAS_PROGRESS = True +except: + HAS_PROGRESS = False + Progress = ProgressBar + +class Processor(Enum): + FFMPEG = 1 + IMAGEMAGICK = 2 + +PROFILES = [ + { + "name": "half_small", + "desc": "Scale image to half, and compress to low-quality AVIF", + "processor": "IMAGEMAGICK", + "command": "-scale 50% -quality 70", + "output_ext": "avif", + }, { + "name": "intermediate", + "desc": "Convert video to ProRes", + "processor": "FFMPEG", + "command": "-c:v prores_ks -profile:v 3 -qscale:v 9 -vendor apl0 -pix_fmt yuv422p10le -c:a copy", + "output_ext": "mov", + }] + +class Profile: + def __init__(self, profile_dict): + self.name = profile_dict.get("name", "") + self.desc = profile_dict.get("desc", "") + self.processor = Processor[profile_dict.get("processor", "IMAGEMAGICK")] + self.command = profile_dict.get("command", "") + self.output_ext = profile_dict.get("output_ext", "") + + @classmethod + def get(cls, name): + for p in PROFILES: + if p["name"] == name: + return cls(p) + + def __str__(self): + return "{} ({}): {}\n{}".format(self.name, self.processor.name, + self.desc, self.command) + +def getConfigFiles(): + files = [] + f = os.path.join(os.path.dirname(os.path.realpath(__file__)), + "ffmagick.toml") + files.append((f, os.path.exists(f))) + f = "/etc/ffmagick.toml" + files.append((f, os.path.exists(f))) + if "HOME" in os.environ: + home = os.environ["HOME"] + if os.name == "nt" and home.endswith(':') and home.find('/') == -1 \ + and home.find('\\') == -1: + # $HOME is a drive letter (e.g. “D:”) on Windows. + f = os.path.join(home, os.sep, ".config", "ffmagick.toml") + else: + f = os.path.join(home, ".config", "ffmagick.toml") + files.append((f, os.path.exists(f))) + return files + +class Configuration: + def __init__(self): + self._imagemagick_bin = None + self._ffmpeg_bin = None + self.profiles = [] + + # Get configuration from files in the system. + @classmethod + def get(cls): + conf = cls() + for f in getConfigFiles(): + if f[1]: + conf.merge(Configuration.readFromFile(f[0])) + return conf + + @property + def imagemagick_bin(self): + if self._imagemagick_bin is None: + return "magick" + else: + return self._imagemagick_bin + + @property + def ffmpeg_bin(self): + if self._ffmpeg_bin is None: + return "ffmpeg" + else: + return self._ffmpeg_bin + + @classmethod + def readFromFile(cls, filename): + with open(filename, 'rb') as f: + data = toml.load(f) + + result = cls() + for key in data: + item = data[key] + if isinstance(item, dict): + p = Profile(item) + p.name = key + result.profiles.append(p) + else: + if key == "imagemagick_bin": + result._imagemagick_bin = item + elif key == "ffmpeg_bin": + result._ffmpeg_bin = item + else: + print("ERROR: invalid configuration key:", key) + return result + + def merge(self, conf): + if conf._imagemagick_bin is not None: + self._imagemagick_bin = conf._imagemagick_bin + if conf._ffmpeg_bin is not None: + self._ffmpeg_bin = conf._ffmpeg_bin + self.profiles = conf.profiles + self.profiles + + def getProfile(self, name): + for p in self.profiles: + if p.name == name: + return p + +def runOnce(config, profile, input_file): + output_file = os.path.splitext(input_file)[0] + "." + profile.output_ext + if profile.processor == Processor.FFMPEG: + cmd = "\"{}\" -y -i \"{}\" {} \"{}\"".format( + config.ffmpeg_bin, input_file, profile.command, output_file) + elif profile.processor == Processor.IMAGEMAGICK: + cmd = "\"{}\" \"{}\" {} \"{}\"".format( + config.imagemagick_bin, input_file, profile.command, output_file) + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True) + +def runAll(config, profile, input_files): + bar = Progress("Processing", max=len(input_files)) + for f in input_files: + bar.next() + result = runOnce(config, profile, f) + if result.returncode != 0: + bar.finish() + print("ERROR: failed to process {}. Log:\n") + print(result.stdout) + return False + bar.finish() + return True + +def main(): + import argparse + parser = argparse.ArgumentParser( + prog='FFmagick', description='What the program does') + parser.add_argument('files', metavar="FILE", nargs='*', + help="File to process") + parser.add_argument('-p', "--profile", metavar="PROFILE", dest="profile", + help="Specify the profile to process the files") + parser.add_argument("-l", "--list-profiles", action="store_true", + dest="should_list_profiles", + help="List all profiles") + parser.add_argument("--list-config-files", action="store_true", + dest="should_list_conf_files", + help="List all config files found") + args = parser.parse_args() + + config = Configuration.get() + if args.should_list_profiles: + for p in config.profiles: + print(p) + print() + return 0 + + if args.should_list_conf_files: + for f in getConfigFiles(): + print("✅" if f[1] else "❌", f[0]) + return 0 + + if "profile" not in args: + print("Profile needed") + return 1 + + if not runAll(config, config.getProfile(args.profile), args.files): + return 1 + + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ffmagick/ffmagick.toml b/ffmagick/ffmagick.toml new file mode 100644 index 0000000..4f8b1da --- /dev/null +++ b/ffmagick/ffmagick.toml @@ -0,0 +1,17 @@ +[half_small] +desc = "Scale image to half, and compress to low-quality AVIF" +processor = "IMAGEMAGICK" +command = "-scale 50% -quality 70" +output_ext = "avif" + +[intermediate] +desc = "Convert video to ProRes" +processor = "FFMPEG" +command = "-c:v prores_ks -profile:v 3 -qscale:v 9 -vendor apl0 -pix_fmt yuv422p10le -c:a copy" +output_ext = "mov" + +[acodyssey_hdr] +desc = "Convert ACOdyssey HDR screenshot to SDR" +processor = "IMAGEMAGICK" +command = "-colorspace LAB -channel 0 -fx '.07*exp(-u*2)+u - 0.4*u^2 + 0.4 * u' -colorspace sRGB" +output_ext = "png" -- cgit v1.2.3-70-g09d2