BareGit

Add ffmagick

Author: MetroWind <chris.corsair@gmail.com>
Date: Sun Nov 2 14:12:20 2025 -0800
Commit: a5a39011510852f95f01b0abfd9f0206b438ea8f

Changes

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"