summaryrefslogtreecommitdiff
path: root/ffmagick/ffmagick.py
diff options
context:
space:
mode:
authorMetroWind <chris.corsair@gmail.com>2025-11-02 14:12:20 -0800
committerMetroWind <chris.corsair@gmail.com>2025-11-02 14:12:20 -0800
commita5a39011510852f95f01b0abfd9f0206b438ea8f (patch)
tree63c2b41147a940ea426cdfd21a5850f25a56dc05 /ffmagick/ffmagick.py
Add ffmagick
Diffstat (limited to 'ffmagick/ffmagick.py')
-rw-r--r--ffmagick/ffmagick.py210
1 files changed, 210 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
3import sys, os
4import argparse
5import subprocess
6from enum import Enum
7import tomllib as toml
8
9# Simple progress display, in a similar interface to the Progress library.
10class 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
24try:
25 from progress.bar import Bar as Progress
26 HAS_PROGRESS = True
27except:
28 HAS_PROGRESS = False
29 Progress = ProgressBar
30
31class Processor(Enum):
32 FFMPEG = 1
33 IMAGEMAGICK = 2
34
35PROFILES = [
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
50class 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
68def 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
86class 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
148def 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
159def 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
172def 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
209if __name__ == "__main__":
210 sys.exit(main())