├── README.md ├── config.ini ├── ffmpegify.nemo_action ├── ffmpegify.py ├── ffmpegify.reg ├── ffmpegifyConfigure.pyw ├── img ├── example.gif └── osxsetup.png ├── requirements.txt └── settings.json /README.md: -------------------------------------------------------------------------------- 1 | # FFmpegify 2 | 3 | A Context Menu script for fast conversion of image sequences to videos, or videos to other formats 4 | - Supports JPG, PNG, TIFF, TGA and EXR sequence image inputs, and MOV and MP4 video inputs 5 | - Supports arbitrary starting frame numbers and frame number padding 6 | - Supports MOV, MP4, PNG Sequence and PNG, JPG, TIFF sequence outputs 7 | - Option for maximum output width and height (maintains aspect ratio) 8 | - Applies a premultiply filter for better conversion of transparent images and gamma adjust linear image sequences (EXR, TGA) 9 | - Many settings can be adjusted with the 'FFmpegifySettings' dialog accessed by right-clicking an empty area in File Explorer 10 | 11 | # Windows Installation 12 | - Install Python3. Install FFmpeg and ensure it is available to the command line (i.e added to the PATH environment variable) 13 | - Install the necessary python libraries with `pip install -r requirements.txt` 14 | - Adjust the entries in 'ffmpegify.reg' to point to the correct Python install and FFmpegify locations and run the file. FFmpegify will appear as a context menu item for all file types. 15 | 16 | # Mac Installation 17 | - Install Python3 (the default install location is /usr/local/bin/python3) 18 | - Install FFmpeg. The easiest way to do this is to install Homebrew https://brew.sh/ and then run `brew install ffmpeg` in a terminal 19 | - Download and extract this repository to your chosen location 20 | - Open 'Automator' and create a new 'Run Shell Script' automation as shown, with the ffmpegify path set to your chosen location 21 | ![alt text](https://github.com/Aeoll/FFmpegify/blob/master/img/osxsetup.png "osxsetup") 22 | - FFmpegify will appear at the bottom of the Finder context menu for all filetypes 23 | 24 | # Linux Installation (Nemo) 25 | - Install the latest version of FFmpeg 26 | - To add to the context menu of the Nemo file manager you use of nemo actions, described here https://wiki.archlinux.org/index.php/Nemo#Nemo_Actions 27 | - Copy ffmpegify.nemo_action to /usr/share/nemo/actions/ 28 | - Copy ffmpegify.py to /usr/share/nemo/actions/ (ensure root has execution rights) 29 | 30 | # Tips 31 | - You can edit the 'config.ini' script to define a custom ffmpeg path or a custom path for the 'settings.json' file which contains video conversion options. 32 | - The frame numbering should be directly before the extension. (e.g MySequence.0034.jpg) 33 | - The script works for any frame number selected - you do not need to select the fist frame in the sequence. -------------------------------------------------------------------------------- /config.ini: -------------------------------------------------------------------------------- 1 | ; configuration for non-default locations of ffmpeg or 'settings.json'. Uncomment required 2 | [config] 3 | ;ffmpeg=C:/_INSTALL/ffmpeg/bin/ffmpeg.exe 4 | ;settings=C:/_INSTALL/settings.json -------------------------------------------------------------------------------- /ffmpegify.nemo_action: -------------------------------------------------------------------------------- 1 | [Nemo Action] 2 | Active=true 3 | Name=FFmpegify 4 | Comment=FFmpegify 5 | Exec= 6 | Selection=S 7 | Extensions=any; -------------------------------------------------------------------------------- /ffmpegify.py: -------------------------------------------------------------------------------- 1 | import re, sys, glob, os, time, json, subprocess 2 | from pathlib import Path 3 | import ffmpeg 4 | 5 | standardImg = [".jpg", ".jpeg", ".png", ".tiff", ".tif"] 6 | linearImg = [".exr", ".tga"] 7 | imgTypes = standardImg + linearImg 8 | vidTypes = [".mov", ".mp4", ".webm", ".mkv", ".avi"] 9 | vidOutput = ["mov", "mp4", "mp4-via-jpg", "webm"] 10 | 11 | class FFMPEGIFY: 12 | def __init__(self, config): 13 | self.CUSTOM_FFMPEG = None 14 | self.AUDIO = None 15 | self.STREAMINFO = None 16 | self.AUDIOINFO = None 17 | 18 | ''' 19 | Extract config to constants 20 | ''' 21 | self.START_FRAME = int(config["startFrame"]) 22 | self.MAX_FRAMES = int(config["maxFrames"]) 23 | self.MAX_WIDTH = int(config["maxWidth"]) 24 | self.MAX_HEIGHT = int(config["maxHeight"]) 25 | self.SCALER = config["scaler"] 26 | self.CRF = int(config["quality"]) 27 | self.FRAME_RATE = int(config["FPS"]) 28 | self.PRESET = config["preset"] 29 | self.CODEC = config["codec"] 30 | self.VIDFORMAT = config["format"] 31 | if self.CODEC == "ProResHQ": 32 | self.VIDFORMAT = "mov" 33 | 34 | self.GAMMA = config["gamma"] 35 | self.PREMULT = config["premult"] 36 | self.NAME_LEVELS = int(config["namelevels"]) # Create output file in higher-up directories 37 | self.USE_AUDIO = config["useaudio"] 38 | self.AUDIO_OFFSET = int(config["audiooffset"]) 39 | 40 | self.isVidOut = True # Check if being output to video or frames 41 | if self.VIDFORMAT not in vidOutput: 42 | self.isVidOut = False 43 | 44 | def set_ffmpeg(self, ffmpeg_path): 45 | ''' 46 | Set a custom ffmpeg location 47 | ''' 48 | if ffmpeg_path: 49 | self.CUSTOM_FFMPEG = ffmpeg_path 50 | 51 | def get_metadata(self, inputf): 52 | ''' 53 | Use ffprobe to load metadata 54 | ''' 55 | # ffprobeInfo = ffmpeg.probe(str(inputf)) 56 | ffprobeInfo = ffmpeg.probe(str(inputf), v = "quiet") 57 | self.STREAMINFO = ffprobeInfo["streams"][0] 58 | if ffprobeInfo["format"]["nb_streams"] > 1: 59 | self.AUDIOINFO = ffprobeInfo["streams"][1] 60 | 61 | def get_input_file(self, path): 62 | ''' 63 | Get the input selected frame. If the input path is a directory it searches contained files for a valid frame. 64 | ''' 65 | infile = Path(path) 66 | if os.path.isdir(path): 67 | files = os.listdir(path) 68 | for f in files: 69 | fpath = Path(f) 70 | if fpath.suffix in imgTypes: 71 | infile = infile.joinpath(fpath) 72 | break 73 | return infile 74 | 75 | def get_output_filename(self, infile): 76 | ''' 77 | Creates the output filepath. Avoids overwrites 78 | ''' 79 | saveDir = infile # naming the video file based on parent dir. Could change this later. 80 | parts = infile.parent.parts 81 | if self.NAME_LEVELS > 0: 82 | sec = len(parts) - self.NAME_LEVELS 83 | parts = parts[sec:] 84 | outname = "_".join(parts) 85 | else: 86 | outname = str(infile.parent) 87 | outname = re.sub(r"\W+", "_", outname) 88 | outputf = str(saveDir.with_name("_" + outname + "_video." + self.VIDFORMAT)) 89 | if not self.isVidOut: 90 | outputf = str( 91 | saveDir.with_name( 92 | "_" + preframepart + "_" + padstring + "." + self.VIDFORMAT 93 | ) 94 | ) 95 | 96 | # If the video already exists create do not overwrite it 97 | counter = 1 98 | while Path(outputf).exists(): 99 | outputf = str( 100 | saveDir.with_name( 101 | "_" + outname + "_video_" + str(counter) + "." + self.VIDFORMAT 102 | ) 103 | ) 104 | counter = counter + 1 105 | return outputf 106 | 107 | def input_stream(self, infile): 108 | ''' 109 | Generate ffmpeg arg stream. Arguments for the input stream would be placed prior to -i on commandline? 110 | ''' 111 | IN_ARGS = (dict()) 112 | 113 | # Parse input filename for framenumber. simple regex match - find digits at the end of the filename. 114 | # Examples: frame.0001.exr, frame0001.exr, frame1.exr 115 | stem = infile.stem 116 | suffix = infile.suffix 117 | l = len(stem) 118 | back = stem[::-1] 119 | m = re.search("\d+", back) 120 | if m: 121 | sp = m.span(0) 122 | sp2 = [l - a for a in sp] 123 | sp2.reverse() 124 | 125 | # glob for other frames in the folder and find the first frame to use as start number 126 | preframepart = stem[0 : sp2[0]] 127 | postframepart = stem[sp2[1] :] 128 | frames = sorted( 129 | infile.parent.glob(preframepart + "*" + postframepart + suffix) 130 | ) 131 | start_num = int(frames[0].name[sp2[0] : sp2[1]]) 132 | if self.START_FRAME > 0: 133 | start_num = self.START_FRAME 134 | 135 | # get padding for frame num 136 | padding = sp2[1] - sp2[0] 137 | padstring = "%" + format(padding, "02") + "d" # eg %05d 138 | # fix for unpadded frame numbers 139 | if len(frames[0].name) != len(frames[-1].name): 140 | padstring = "%" + "d" 141 | 142 | # get absolute path to the input file 143 | inputf = stem[0 : sp2[0]] + padstring + postframepart + suffix 144 | inputf_abs = str(infile.with_name(inputf)) 145 | 146 | if suffix in linearImg: 147 | IN_ARGS["gamma"] = self.GAMMA 148 | IN_ARGS["start_number"] = str(start_num).zfill(padding) 149 | IN_ARGS["framerate"] = str(self.FRAME_RATE) 150 | STREAM = ffmpeg.input(inputf_abs, **IN_ARGS) 151 | return STREAM 152 | return None 153 | 154 | def add_audio(self, infile, STREAM): 155 | ''' 156 | Search for adjacent audio files and add to the stream 157 | ''' 158 | try: 159 | tracks = [] 160 | tracks.extend(sorted(infile.parent.glob("*.mp3"))) 161 | tracks.extend(sorted(infile.parent.glob("*.wav"))) 162 | if tracks and self.USE_AUDIO: 163 | self.AUDIO = ffmpeg.input( 164 | str(tracks[0]), **{"itsoffset": str(self.AUDIO_OFFSET)} 165 | ) 166 | print("Found audio tracks: " + str(tracks)) 167 | except Exception as e: 168 | print("Error adding audio: " + str(e)) 169 | 170 | def add_scaling(self, STREAM): 171 | ''' 172 | Scale the output. FFprobe is used to get input image/video dimensions 173 | ''' 174 | scalekw = {} 175 | scalekw["out_color_matrix"] = "bt709" 176 | iw = self.STREAMINFO["width"] # "coded_width" can be larger than 'width' and causes errors in vid-vid conversion? 177 | ih = self.STREAMINFO["height"] # "coded_height" can be larger than 'height' and causes errors in vid-vid conversion? 178 | # Round to even pixel dimensions 179 | rounded_w = iw - (iw % 2) 180 | rounded_h = ih - (ih % 2) 181 | downscale_w = min(self.MAX_WIDTH, rounded_w) 182 | downscale_h = min(self.MAX_HEIGHT, rounded_h) 183 | print("iw: {} ih: {} downscale_w: {} downscale_h: {}".format(iw, ih, downscale_w, downscale_h)) 184 | 185 | # If no max dim just crop odd pixels 186 | if self.MAX_HEIGHT <= 0 and self.MAX_WIDTH <= 0: 187 | scale = [rounded_w, rounded_h] 188 | return STREAM.filter("crop", scale[0], scale[1]) 189 | 190 | if (self.MAX_WIDTH) > 0 and (self.MAX_HEIGHT > 0): 191 | # The smaller downscale_w / rounded_w, the more extreme the cropping (1.0 means no cropping) 192 | # If width cropping is more extreme, we can set height cropping to 0 193 | if (downscale_w / rounded_w) > (downscale_h / rounded_h): 194 | self.MAX_WIDTH = 0 195 | else: 196 | self.MAX_HEIGHT = 0 197 | 198 | if self.MAX_WIDTH == 0: 199 | scale = ["-2", downscale_h] 200 | elif self.MAX_HEIGHT == 0: 201 | scale = [downscale_w, "-2"] 202 | 203 | return STREAM.filter("scale", scale[0], scale[1], **scalekw) 204 | 205 | def build_output(self, infile, STREAM): 206 | ''' 207 | Construct output stream arguments and output filepath (image sequence input) 208 | ''' 209 | outputf = self.get_output_filename(infile) 210 | OUT_ARGS = dict() 211 | 212 | if self.isVidOut: 213 | if self.CODEC == "H.264": 214 | OUT_ARGS["vcodec"] = "libx264" 215 | OUT_ARGS["vprofile"] = "baseline" 216 | OUT_ARGS["pix_fmt"] = "yuv420p" 217 | OUT_ARGS["crf"] = str(self.CRF) 218 | OUT_ARGS["preset"] = self.PRESET 219 | OUT_ARGS[ 220 | "x264opts" 221 | ] = "colorprim=bt709:transfer=bt709:colormatrix=smpte170m" 222 | OUT_ARGS["max_muxing_queue_size"] = 4096 223 | elif self.CODEC == "ProResHQ": 224 | OUT_ARGS["vcodec"] = "prores" 225 | OUT_ARGS["profile"] = "3" # 422 HQ 226 | OUT_ARGS["pix_fmt"] = "yuv422p10le" 227 | OUT_ARGS["qscale"] = "13" # prores quality - could add configuration option for this? 9-13 is recommended 228 | else: 229 | pass 230 | 231 | if self.VIDFORMAT == "webm": 232 | # Possibly implement two-pass for webm 233 | OUT_ARGS = dict() 234 | OUT_ARGS["crf"] = str(self.CRF) 235 | OUT_ARGS["vcodec"] = "libvpx-vp9" 236 | OUT_ARGS["b:v"] = 0 237 | elif self.VIDFORMAT == "jpg": 238 | OUT_ARGS["q:v"] = "2" 239 | 240 | if self.MAX_FRAMES > 0: 241 | OUT_ARGS["-vframes"] = str(self.MAX_FRAMES) 242 | 243 | # Add premult filter. Maybe causing problems?? 244 | if self.isVidOut and self.PREMULT: 245 | STREAM = STREAM.filter("premultiply", inplace="1") 246 | 247 | # Add scaling 248 | STREAM = self.add_scaling(STREAM) 249 | OUT_ARGS["sws_flags"] = self.SCALER 250 | 251 | # Add audio options if audio stream added 252 | if self.AUDIO: 253 | OUT_ARGS["c:a"] = "aac" 254 | OUT_ARGS["b:a"] = "320k" 255 | OUT_ARGS["shortest"] = None 256 | STREAM = STREAM.output(self.AUDIO, outputf, **OUT_ARGS) 257 | print("GOT AUDIO") 258 | else: 259 | OUT_ARGS["an"] = None 260 | STREAM = STREAM.output(outputf, **OUT_ARGS) 261 | print("NO AUDIO") 262 | return STREAM 263 | 264 | def has_audio_streams(self, file_path): 265 | streams = ffmpeg.probe(file_path)["streams"] 266 | for stream in streams: 267 | if stream["codec_type"] == "audio": 268 | return True 269 | return False 270 | 271 | def build_output_video_to_video(self, infile): 272 | ''' 273 | Construct output stream arguments and output filepath (video as input) 274 | TODO vid-vid convert with audio? 275 | https://github.com/kkroening/ffmpeg-python/issues/204 276 | ''' 277 | stem = infile.stem 278 | saveDir = infile 279 | 280 | STREAM = ffmpeg.input(str(infile)) 281 | audio_in = STREAM.audio 282 | 283 | OUT_ARGS = dict() 284 | if self.isVidOut: 285 | template = "_converted." 286 | if self.CODEC == "H.264": 287 | OUT_ARGS["vcodec"] = "libx264" 288 | OUT_ARGS["pix_fmt"] = "yuv420p" 289 | OUT_ARGS["vprofile"] = "baseline" 290 | OUT_ARGS["crf"] = str(self.CRF) 291 | OUT_ARGS["preset"] = self.PRESET 292 | elif self.CODEC == "ProResHQ": 293 | OUT_ARGS["vcodec"] = "prores" 294 | OUT_ARGS["profile"] = "3" 295 | OUT_ARGS["pix_fmt"] = "yuv422p10le" 296 | OUT_ARGS["qscale"] = "13" 297 | 298 | if not self.AUDIOINFO: 299 | OUT_ARGS["an"] = None 300 | print("NO AUDIO") 301 | else: 302 | template = "_converted.%04d." # Output image sequence 303 | 304 | STREAM = self.add_scaling(STREAM) 305 | OUT_ARGS["sws_flags"] = self.SCALER 306 | 307 | outputf = str(saveDir.with_name(stem + template + self.VIDFORMAT)) 308 | STREAM = ffmpeg.output(STREAM, audio_in, outputf, **OUT_ARGS) 309 | return STREAM 310 | 311 | def convert(self, path): 312 | ''' 313 | Compile final command and run ffmpeg 314 | ''' 315 | 316 | infile = self.get_input_file(path) 317 | self.get_metadata(infile) 318 | suffix = str(infile.suffix) 319 | if suffix in imgTypes: 320 | STREAM = self.input_stream(infile) 321 | if not STREAM: 322 | print("Cannot find valid input file.") 323 | return 324 | self.add_audio(infile, STREAM) 325 | STREAM = self.build_output(infile, STREAM) 326 | elif suffix in vidTypes: 327 | STREAM = self.build_output_video_to_video(infile) 328 | else: 329 | print("Invalid file extension: " + str(suffix)) 330 | return 331 | 332 | # (stdout, stderr) = STREAM.run(capture_stdout=True, capture_stderr=True) # original call - using ffmpeg.run() 333 | if not self.CUSTOM_FFMPEG: 334 | cmd = STREAM.compile() 335 | else: 336 | cmd = STREAM.compile(cmd=self.CUSTOM_FFMPEG) 337 | 338 | cmd = [re.sub(r'^(\d:a)$', '\\1?', arg) for arg in cmd] # add optional (?) flag to audio streams. Prevents error in vid->vid if no audio present 339 | print(" ".join(cmd)) 340 | p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 341 | stdout, stderr = p.communicate() 342 | print(stderr.decode("utf-8")) 343 | 344 | # ================================================================= 345 | # Configuration readers and main entry point 346 | # ================================================================= 347 | 348 | from configparser import ConfigParser 349 | 350 | def read_config(config_file): 351 | ''' 352 | Read config.ini. Used to define custom ffmpeg / settings.json locations 353 | ''' 354 | config = ConfigParser() 355 | config.read(str(config_file)) 356 | 357 | custom_ffmpeg_path = None 358 | try: 359 | ffmpeg_path = config.get('config', 'ffmpeg') 360 | # set the custom ffmpeg location if it exists 361 | if ffmpeg_path and Path(ffmpeg_path).exists(): 362 | custom_ffmpeg_path = str(ffmpeg_path) 363 | except: 364 | print("custom ffmpeg path not used") 365 | 366 | # set the custom settings json file if it exists 367 | custom_settings_file = str(config_file.with_name("settings.json")) 368 | try: 369 | settings_file = config.get('config', 'settings') 370 | if settings_file and Path(settings_file).exists(): 371 | custom_settings_file = str(settings_file) 372 | else: 373 | print("unable to find settings file in custom location - using default location") 374 | except: 375 | print("custom settings.json path not used") 376 | 377 | return custom_ffmpeg_path, custom_settings_file 378 | 379 | def read_settings(settings_file): 380 | ''' 381 | Read json settings file which defines video conversion parameters 382 | ''' 383 | try: 384 | with open(settings_file, "r") as f: 385 | config = json.load(f) 386 | return config 387 | except Exception as e: 388 | print(e) 389 | 390 | 391 | if __name__ == "__main__": 392 | path = Path(sys.argv[0]) 393 | custom_ffmpeg, settings_file = read_config(path.with_name("config.ini")) 394 | 395 | settings = read_settings(settings_file) 396 | F = FFMPEGIFY(settings) 397 | F.set_ffmpeg(custom_ffmpeg) 398 | try: 399 | F.convert(sys.argv[1]) 400 | # input() 401 | except Exception as e: 402 | import traceback 403 | traceback.print_exc(file=sys.stdout) 404 | input() 405 | -------------------------------------------------------------------------------- /ffmpegify.reg: -------------------------------------------------------------------------------- 1 | Windows Registry Editor Version 5.00 2 | 3 | [HKEY_CLASSES_ROOT\*\shell\ffmpegify] 4 | @="FFmpegify" 5 | 6 | [HKEY_CLASSES_ROOT\*\shell\ffmpegify\command] 7 | @="\"C:\\Python38\\python.exe\" \"C:\\FFmpegify\\ffmpegify.py\" \"%1\"" 8 | 9 | [HKEY_CLASSES_ROOT\Directory\shell\ffmpegify] 10 | @="FFmpegify" 11 | 12 | [HKEY_CLASSES_ROOT\Directory\shell\ffmpegify\command] 13 | @="\"C:\\Python38\\python.exe\" \"C:\\FFmpegify\\ffmpegify.py\" \"%1\"" 14 | 15 | [HKEY_CLASSES_ROOT\Directory\background\shell\ffmpegifySettings] 16 | @="FFmpegifySettings" 17 | 18 | [HKEY_CLASSES_ROOT\Directory\background\shell\ffmpegifySettings\command] 19 | @="\"C:\\Python38\\pythonw.exe\" \"C:\\FFmpegify\\ffmpegifyConfigure.pyw\"" -------------------------------------------------------------------------------- /ffmpegifyConfigure.pyw: -------------------------------------------------------------------------------- 1 | from PySide2.QtGui import * 2 | from PySide2.QtWidgets import * 3 | from PySide2.QtCore import * 4 | import os 5 | import sys 6 | import json 7 | from pathlib import * 8 | 9 | class FFMPEGIFY_CONFIGURE(QDialog): 10 | presets = ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"] 11 | formats = ["mov", "mp4", "webm", "png", "tiff", "jpg"] 12 | codecs = ["H.264", "ProResHQ"] 13 | scalers = ["bicubic", "bilinear", "lanczos", "neighbor"] 14 | 15 | def __init__(self, settings_file): 16 | super().__init__() 17 | self.settings_file = settings_file 18 | self.settings_file_fallback = str(Path(os.path.dirname(os.path.realpath(__file__))).joinpath("settings.json")) 19 | self.initUI() 20 | 21 | def initUI(self): 22 | self.cf = self.readSettings() 23 | 24 | # main top level layout 25 | mainlayout = QVBoxLayout() 26 | 27 | layout = QFormLayout() 28 | # FRAMERATE 29 | self.fps_label = QLabel("Frame Rate") 30 | self.fps_widget = QSpinBox() 31 | self.fps_widget.setValue(int(self.cf['FPS'])) 32 | layout.addRow(self.fps_label, self.fps_widget) 33 | 34 | # QUALITY 35 | self.cf_label = QLabel("Quality (0-51: Lower=Better Quality)") 36 | self.cf_widget = QSpinBox() 37 | self.cf_widget.setValue(int(self.cf['quality'])) 38 | layout.addRow(self.cf_label, self.cf_widget) 39 | 40 | # MAXWIDTH 41 | self.maxw_label = QLabel("Maximum Width (0 to disable)") 42 | self.maxw_widget = QSpinBox() 43 | self.maxw_widget.setRange(0, 5000) 44 | self.maxw_widget.setValue(int(self.cf['maxWidth'])) 45 | layout.addRow(self.maxw_label, self.maxw_widget) 46 | 47 | # MAXHEIGHT 48 | self.maxh_label = QLabel("Maximum Height (0 to disable)") 49 | self.maxh_widget = QSpinBox() 50 | self.maxh_widget.setRange(0, 5000) 51 | self.maxh_widget.setValue(int(self.cf['maxHeight'])) 52 | layout.addRow(self.maxh_label, self.maxh_widget) 53 | 54 | # SCALER 55 | self.scaler_label = QLabel("Scaling Algorithm") 56 | self.scaler_widget = QComboBox() 57 | for p in self.scalers: 58 | self.scaler_widget.addItem(p) 59 | self.scaler_widget.setCurrentText(self.cf['scaler']) 60 | layout.addRow(self.scaler_label, self.scaler_widget) 61 | 62 | # START FRAME 63 | self.startframe_label = QLabel("Start Frame (0 for first in sequence)") 64 | self.startframe_widget = QSpinBox() 65 | self.startframe_widget.setRange(0, 5000) 66 | self.startframe_widget.setValue(int(self.cf['startFrame'])) 67 | layout.addRow(self.startframe_label, self.startframe_widget) 68 | 69 | # END FRAME (LEN) 70 | self.endframe_label = QLabel("Max Frames (0 for no maximum)") 71 | self.endframe_widget = QSpinBox() 72 | self.endframe_widget.setRange(0, 5000) 73 | self.endframe_widget.setValue(int(self.cf['maxFrames'])) 74 | layout.addRow(self.endframe_label, self.endframe_widget) 75 | 76 | # GAMMA 77 | self.gamma_label = QLabel("Gamma (EXR/TGA only)") 78 | self.gamma_widget = QDoubleSpinBox() 79 | self.gamma_widget.setValue(float(self.cf['gamma'])) 80 | layout.addRow(self.gamma_label, self.gamma_widget) 81 | 82 | # PREMULT 83 | self.premult_label = QLabel("Premultiply Alpha") 84 | self.premult_widget = QCheckBox() 85 | self.premult_widget.setTristate(False) 86 | self.premult_widget.setChecked(self.cf['premult']=="True") 87 | layout.addRow(self.premult_label, self.premult_widget) 88 | 89 | # PRESET 90 | self.preset_label = QLabel("Preset") 91 | self.preset_widget = QComboBox() 92 | for p in self.presets: 93 | self.preset_widget.addItem(p) 94 | self.preset_widget.setCurrentText(self.cf['preset']) 95 | layout.addRow(self.preset_label, self.preset_widget) 96 | 97 | # CODEC 98 | self.codec_label = QLabel("Codec") 99 | self.codec_widget = QComboBox() 100 | for p in self.codecs: 101 | self.codec_widget.addItem(p) 102 | self.codec_widget.setCurrentText(self.cf['codec']) 103 | layout.addRow(self.codec_label, self.codec_widget) 104 | 105 | # FORMAT 106 | self.format_label = QLabel("Format") 107 | self.format_widget = QComboBox() 108 | for p in self.formats: 109 | self.format_widget.addItem(p) 110 | self.format_widget.setCurrentText(self.cf['format']) 111 | layout.addRow(self.format_label, self.format_widget) 112 | 113 | # NAMING LEVELS 114 | self.namelevels_label = QLabel("Naming Levels (0 for full path)") 115 | self.namelevels_widget = QSpinBox() 116 | self.namelevels_widget.setRange(0, 10) 117 | self.namelevels_widget.setValue(int(self.cf['namelevels'])) 118 | layout.addRow(self.namelevels_label, self.namelevels_widget) 119 | 120 | # AUDIO 121 | self.audio_label = QLabel("Enable Audio") 122 | self.audio_widget = QCheckBox() 123 | self.audio_widget.setTristate(False) 124 | self.audio_widget.setChecked(self.cf['useaudio']=="True") 125 | layout.addRow(self.audio_label, self.audio_widget) 126 | 127 | # AUDIO OFFSET 128 | self.audiooffset_label = QLabel("Audio Offset (Seconds)") 129 | self.audiooffset_widget = QSpinBox() 130 | self.audiooffset_widget.setRange(-200, 200) 131 | self.audiooffset_widget.setValue(int(self.cf['audiooffset'])) 132 | layout.addRow(self.audiooffset_label, self.audiooffset_widget) 133 | 134 | # Add form to main layout 135 | mainlayout.addLayout(layout) 136 | 137 | # Spacer after form 138 | line = QFrame() 139 | line.setMinimumSize(0, 10) 140 | mainlayout.addWidget(line) 141 | 142 | # ButtonBox 143 | self.bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) 144 | self.bbox.setCenterButtons(True) 145 | self.bbox.accepted.connect(self.writeSettings) 146 | self.bbox.rejected.connect(self.reject) 147 | mainlayout.addWidget(self.bbox) 148 | 149 | self.setLayout(mainlayout) 150 | self.setGeometry(350, 300, 250, 150) 151 | self.setWindowTitle('FFmpegify Settings') 152 | self.show() 153 | 154 | # Open it under the cursor 155 | def showEvent(self, event): 156 | geom = self.frameGeometry() 157 | geom.moveCenter(QCursor.pos()) 158 | self.setGeometry(geom) 159 | super(FFMPEGIFY_CONFIGURE, self).showEvent(event) 160 | 161 | # Prevent hitting Enter from pressing OK button 162 | def keyPressEvent(self, event): 163 | if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: 164 | pass 165 | 166 | def readSettings(self): 167 | try: 168 | with open(self.settings_file, 'r') as f: 169 | config = json.load(f) 170 | except: 171 | print("couldn't read custom settings file - using default") 172 | with open(self.settings_file_fallback, 'r') as f: 173 | config = json.load(f) 174 | return config 175 | 176 | # Connect to OK button 177 | def writeSettings(self): 178 | cfg = {} 179 | cfg['FPS'] = str(self.fps_widget.value()) 180 | cfg['startFrame'] = str(self.startframe_widget.value()) 181 | cfg['maxFrames'] = str(self.endframe_widget.value()) 182 | cfg['maxWidth'] = str(self.maxw_widget.value()) 183 | cfg['maxHeight'] = str(self.maxh_widget.value()) 184 | cfg['scaler'] = self.scaler_widget.currentText() 185 | cfg['quality'] = str(self.cf_widget.value()) 186 | cfg['gamma'] = str(self.gamma_widget.value()) 187 | cfg['premult'] = "True" if self.premult_widget.isChecked() else "False" 188 | cfg['preset'] = self.preset_widget.currentText() 189 | cfg['codec'] = self.codec_widget.currentText() 190 | cfg['format'] = self.format_widget.currentText() 191 | cfg['namelevels'] = str(self.namelevels_widget.value()) 192 | cfg['useaudio'] = "True" if self.audio_widget.isChecked() else "False" 193 | cfg['audiooffset'] = str(self.audiooffset_widget.value()) 194 | 195 | sf = Path(self.settings_file) 196 | if not sf.exists(): 197 | anchor = Path(sf.anchor) 198 | if anchor.exists(): 199 | print(str(self.settings_file)) 200 | sf.parent.mkdir(parents=True, exist_ok=True) 201 | else: 202 | print("unable to create settings file in custom location - using default location") 203 | self.settings_file = self.settings_file_fallback 204 | 205 | with open(self.settings_file, 'w') as f: 206 | json.dump(cfg, f, indent=4, sort_keys=False) 207 | self.accept() 208 | 209 | # ================================================================= 210 | # Configuration and main entry point 211 | # ================================================================= 212 | 213 | from configparser import ConfigParser 214 | 215 | def get_settings_file(config_file): 216 | settings_file_default = config_file.with_name("settings.json") 217 | config = ConfigParser() 218 | config.read(str(config_file)) 219 | 220 | # set the custom settings json file if it exists 221 | try: 222 | settings_file = config.get('config', 'settings') 223 | return str(settings_file) 224 | except: 225 | return str(settings_file_default) 226 | 227 | if __name__ == '__main__': 228 | app = QApplication(sys.argv) 229 | app.setStyle("Fusion") 230 | 231 | path = Path(sys.argv[0]) 232 | settings_file = get_settings_file(path.with_name("config.ini")) 233 | 234 | ex = FFMPEGIFY_CONFIGURE(settings_file) 235 | ex.show() 236 | sys.exit(app.exec_()) -------------------------------------------------------------------------------- /img/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeoll/FFmpegify/5d83e0078108145f6f96e67098e4333ecd30d62c/img/example.gif -------------------------------------------------------------------------------- /img/osxsetup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aeoll/FFmpegify/5d83e0078108145f6f96e67098e4333ecd30d62c/img/osxsetup.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | ffmpeg-python 2 | PySide2 -------------------------------------------------------------------------------- /settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "FPS": "25", 3 | "startFrame": "0", 4 | "maxFrames": "0", 5 | "maxWidth": "0", 6 | "maxHeight": "0", 7 | "scaler": "lanczos", 8 | "quality": "18", 9 | "gamma": "2.2", 10 | "premult": "False", 11 | "preset": "medium", 12 | "codec": "H.264", 13 | "format": "mp4", 14 | "namelevels": "3", 15 | "useaudio": "False", 16 | "audiooffset": "0" 17 | } --------------------------------------------------------------------------------