diff options
Diffstat (limited to 'ffmagick')
| -rw-r--r-- | ffmagick/ffmagick.py | 210 | ||||
| -rw-r--r-- | ffmagick/ffmagick.toml | 17 |
2 files changed, 227 insertions, 0 deletions
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 @@ | |||
| 1 | #!/usr/bin/python | ||
| 2 | |||
| 3 | import sys, os | ||
| 4 | import argparse | ||
| 5 | import subprocess | ||
| 6 | from enum import Enum | ||
| 7 | import tomllib as toml | ||
| 8 | |||
| 9 | # Simple progress display, in a similar interface to the Progress library. | ||
| 10 | class ProgressBar: | ||
| 11 | def __init__(self, text, max): | ||
| 12 | self.max = max | ||
| 13 | self.text = text | ||
| 14 | self.current = 0 | ||
| 15 | |||
| 16 | def next(self): | ||
| 17 | self.current += 1 | ||
| 18 | print("\r{} {}/{}".format(self.text, self.current, self.max), flush=True, | ||
| 19 | end="") | ||
| 20 | |||
| 21 | def finish(self): | ||
| 22 | print(flush=True) | ||
| 23 | |||
| 24 | try: | ||
| 25 | from progress.bar import Bar as Progress | ||
| 26 | HAS_PROGRESS = True | ||
| 27 | except: | ||
| 28 | HAS_PROGRESS = False | ||
| 29 | Progress = ProgressBar | ||
| 30 | |||
| 31 | class Processor(Enum): | ||
| 32 | FFMPEG = 1 | ||
| 33 | IMAGEMAGICK = 2 | ||
| 34 | |||
| 35 | PROFILES = [ | ||
| 36 | { | ||
| 37 | "name": "half_small", | ||
| 38 | "desc": "Scale image to half, and compress to low-quality AVIF", | ||
| 39 | "processor": "IMAGEMAGICK", | ||
| 40 | "command": "-scale 50% -quality 70", | ||
| 41 | "output_ext": "avif", | ||
| 42 | }, { | ||
| 43 | "name": "intermediate", | ||
| 44 | "desc": "Convert video to ProRes", | ||
| 45 | "processor": "FFMPEG", | ||
| 46 | "command": "-c:v prores_ks -profile:v 3 -qscale:v 9 -vendor apl0 -pix_fmt yuv422p10le -c:a copy", | ||
| 47 | "output_ext": "mov", | ||
| 48 | }] | ||
| 49 | |||
| 50 | class Profile: | ||
| 51 | def __init__(self, profile_dict): | ||
| 52 | self.name = profile_dict.get("name", "") | ||
| 53 | self.desc = profile_dict.get("desc", "") | ||
| 54 | self.processor = Processor[profile_dict.get("processor", "IMAGEMAGICK")] | ||
| 55 | self.command = profile_dict.get("command", "") | ||
| 56 | self.output_ext = profile_dict.get("output_ext", "") | ||
| 57 | |||
| 58 | @classmethod | ||
| 59 | def get(cls, name): | ||
| 60 | for p in PROFILES: | ||
| 61 | if p["name"] == name: | ||
| 62 | return cls(p) | ||
| 63 | |||
| 64 | def __str__(self): | ||
| 65 | return "{} ({}): {}\n{}".format(self.name, self.processor.name, | ||
| 66 | self.desc, self.command) | ||
| 67 | |||
| 68 | def getConfigFiles(): | ||
| 69 | files = [] | ||
| 70 | f = os.path.join(os.path.dirname(os.path.realpath(__file__)), | ||
| 71 | "ffmagick.toml") | ||
| 72 | files.append((f, os.path.exists(f))) | ||
| 73 | f = "/etc/ffmagick.toml" | ||
| 74 | files.append((f, os.path.exists(f))) | ||
| 75 | if "HOME" in os.environ: | ||
| 76 | home = os.environ["HOME"] | ||
| 77 | if os.name == "nt" and home.endswith(':') and home.find('/') == -1 \ | ||
| 78 | and home.find('\\') == -1: | ||
| 79 | # $HOME is a drive letter (e.g. “D:”) on Windows. | ||
| 80 | f = os.path.join(home, os.sep, ".config", "ffmagick.toml") | ||
| 81 | else: | ||
| 82 | f = os.path.join(home, ".config", "ffmagick.toml") | ||
| 83 | files.append((f, os.path.exists(f))) | ||
| 84 | return files | ||
| 85 | |||
| 86 | class Configuration: | ||
| 87 | def __init__(self): | ||
| 88 | self._imagemagick_bin = None | ||
| 89 | self._ffmpeg_bin = None | ||
| 90 | self.profiles = [] | ||
| 91 | |||
| 92 | # Get configuration from files in the system. | ||
| 93 | @classmethod | ||
| 94 | def get(cls): | ||
| 95 | conf = cls() | ||
| 96 | for f in getConfigFiles(): | ||
| 97 | if f[1]: | ||
| 98 | conf.merge(Configuration.readFromFile(f[0])) | ||
| 99 | return conf | ||
| 100 | |||
| 101 | @property | ||
| 102 | def imagemagick_bin(self): | ||
| 103 | if self._imagemagick_bin is None: | ||
| 104 | return "magick" | ||
| 105 | else: | ||
| 106 | return self._imagemagick_bin | ||
| 107 | |||
| 108 | @property | ||
| 109 | def ffmpeg_bin(self): | ||
| 110 | if self._ffmpeg_bin is None: | ||
| 111 | return "ffmpeg" | ||
| 112 | else: | ||
| 113 | return self._ffmpeg_bin | ||
| 114 | |||
| 115 | @classmethod | ||
| 116 | def readFromFile(cls, filename): | ||
| 117 | with open(filename, 'rb') as f: | ||
| 118 | data = toml.load(f) | ||
| 119 | |||
| 120 | result = cls() | ||
| 121 | for key in data: | ||
| 122 | item = data[key] | ||
| 123 | if isinstance(item, dict): | ||
| 124 | p = Profile(item) | ||
| 125 | p.name = key | ||
| 126 | result.profiles.append(p) | ||
| 127 | else: | ||
| 128 | if key == "imagemagick_bin": | ||
| 129 | result._imagemagick_bin = item | ||
| 130 | elif key == "ffmpeg_bin": | ||
| 131 | result._ffmpeg_bin = item | ||
| 132 | else: | ||
| 133 | print("ERROR: invalid configuration key:", key) | ||
| 134 | return result | ||
| 135 | |||
| 136 | def merge(self, conf): | ||
| 137 | if conf._imagemagick_bin is not None: | ||
| 138 | self._imagemagick_bin = conf._imagemagick_bin | ||
| 139 | if conf._ffmpeg_bin is not None: | ||
| 140 | self._ffmpeg_bin = conf._ffmpeg_bin | ||
| 141 | self.profiles = conf.profiles + self.profiles | ||
| 142 | |||
| 143 | def getProfile(self, name): | ||
| 144 | for p in self.profiles: | ||
| 145 | if p.name == name: | ||
| 146 | return p | ||
| 147 | |||
| 148 | def runOnce(config, profile, input_file): | ||
| 149 | output_file = os.path.splitext(input_file)[0] + "." + profile.output_ext | ||
| 150 | if profile.processor == Processor.FFMPEG: | ||
| 151 | cmd = "\"{}\" -y -i \"{}\" {} \"{}\"".format( | ||
| 152 | config.ffmpeg_bin, input_file, profile.command, output_file) | ||
| 153 | elif profile.processor == Processor.IMAGEMAGICK: | ||
| 154 | cmd = "\"{}\" \"{}\" {} \"{}\"".format( | ||
| 155 | config.imagemagick_bin, input_file, profile.command, output_file) | ||
| 156 | return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, | ||
| 157 | text=True) | ||
| 158 | |||
| 159 | def runAll(config, profile, input_files): | ||
| 160 | bar = Progress("Processing", max=len(input_files)) | ||
| 161 | for f in input_files: | ||
| 162 | bar.next() | ||
| 163 | result = runOnce(config, profile, f) | ||
| 164 | if result.returncode != 0: | ||
| 165 | bar.finish() | ||
| 166 | print("ERROR: failed to process {}. Log:\n") | ||
| 167 | print(result.stdout) | ||
| 168 | return False | ||
| 169 | bar.finish() | ||
| 170 | return True | ||
| 171 | |||
| 172 | def main(): | ||
| 173 | import argparse | ||
| 174 | parser = argparse.ArgumentParser( | ||
| 175 | prog='FFmagick', description='What the program does') | ||
| 176 | parser.add_argument('files', metavar="FILE", nargs='*', | ||
| 177 | help="File to process") | ||
| 178 | parser.add_argument('-p', "--profile", metavar="PROFILE", dest="profile", | ||
| 179 | help="Specify the profile to process the files") | ||
| 180 | parser.add_argument("-l", "--list-profiles", action="store_true", | ||
| 181 | dest="should_list_profiles", | ||
| 182 | help="List all profiles") | ||
| 183 | parser.add_argument("--list-config-files", action="store_true", | ||
| 184 | dest="should_list_conf_files", | ||
| 185 | help="List all config files found") | ||
| 186 | args = parser.parse_args() | ||
| 187 | |||
| 188 | config = Configuration.get() | ||
| 189 | if args.should_list_profiles: | ||
| 190 | for p in config.profiles: | ||
| 191 | print(p) | ||
| 192 | print() | ||
| 193 | return 0 | ||
| 194 | |||
| 195 | if args.should_list_conf_files: | ||
| 196 | for f in getConfigFiles(): | ||
| 197 | print("✅" if f[1] else "❌", f[0]) | ||
| 198 | return 0 | ||
| 199 | |||
| 200 | if "profile" not in args: | ||
| 201 | print("Profile needed") | ||
| 202 | return 1 | ||
| 203 | |||
| 204 | if not runAll(config, config.getProfile(args.profile), args.files): | ||
| 205 | return 1 | ||
| 206 | |||
| 207 | return 0 | ||
| 208 | |||
| 209 | if __name__ == "__main__": | ||
| 210 | 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 @@ | |||
| 1 | [half_small] | ||
| 2 | desc = "Scale image to half, and compress to low-quality AVIF" | ||
| 3 | processor = "IMAGEMAGICK" | ||
| 4 | command = "-scale 50% -quality 70" | ||
| 5 | output_ext = "avif" | ||
| 6 | |||
| 7 | [intermediate] | ||
| 8 | desc = "Convert video to ProRes" | ||
| 9 | processor = "FFMPEG" | ||
| 10 | command = "-c:v prores_ks -profile:v 3 -qscale:v 9 -vendor apl0 -pix_fmt yuv422p10le -c:a copy" | ||
| 11 | output_ext = "mov" | ||
| 12 | |||
| 13 | [acodyssey_hdr] | ||
| 14 | desc = "Convert ACOdyssey HDR screenshot to SDR" | ||
| 15 | processor = "IMAGEMAGICK" | ||
| 16 | command = "-colorspace LAB -channel 0 -fx '.07*exp(-u*2)+u - 0.4*u^2 + 0.4 * u' -colorspace sRGB" | ||
| 17 | output_ext = "png" | ||
