For a long time, renders owned my evenings. Not in a dramatic way — I wasn't staring at a progress bar until midnight — but in the smaller, more annoying way where I'd have to stay at my desk until a render finished so I could start the next one, or come back in the morning to find something had failed halfway through and the whole queue needed restarting.
The fix is embarrassingly simple. A Python script, a watch folder, ffmpeg, and a Slack webhook. Roughly 60 lines. I set up the queue before I leave, and by the time I'm home or back at my desk in the morning, everything's done. If something failed, I know exactly what and why.
Here's how it works.
The basic idea
The script watches a folder on disk. Anything you drop into that folder gets processed through ffmpeg with whatever preset you've defined — ProRes master, H.264 delivery, loudness-normalised audio, whatever you need. Processed files move to an output folder. Files that failed move to an error folder with a log.
No GUI, no database, no running service. Just a script you start and leave running in a terminal session.
The core script
This is a stripped-down version of what I actually use. The real version has more presets and slightly more robust error handling, but this is the shape of it:
import os, time, subprocess, shutil, json from pathlib import Path import requests # pip install requests WATCH = Path("/renders/queue") OUTPUT = Path("/renders/done") ERROR = Path("/renders/error") SLACK = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" FFMPEG_PRESET = [ "ffmpeg", "-i", "{input}", "-c:v", "prores_ks", "-profile:v", "3", "-c:a", "pcm_s24le", "{output}" ] def notify(msg): try: requests.post(SLACK, json={"text": msg}, timeout=5) except: pass def render(src): dst = OUTPUT / (src.stem + ".mov") cmd = [c.replace("{input}", str(src)) .replace("{output}", str(dst)) for c in FFMPEG_PRESET] result = subprocess.run(cmd, capture_output=True) if result.returncode == 0: src.rename(OUTPUT / ("_src_" + src.name)) notify(f"✓ Done: {src.name}") else: src.rename(ERROR / src.name) (ERROR / (src.stem + ".log")).write_text(result.stderr.decode()) notify(f"✗ Failed: {src.name} — check error folder") def watch(): WATCH.mkdir(parents=True, exist_ok=True) OUTPUT.mkdir(parents=True, exist_ok=True) ERROR.mkdir(parents=True, exist_ok=True) seen = set() while True: for f in WATCH.iterdir(): if f.is_file() and f not in seen: seen.add(f) time.sleep(2) # wait for file write to complete render(f) time.sleep(5) watch()
Run it with python3 queue.py inside a tmux or screen session so it keeps running when you close your terminal.
The Slack notification
This is the part that makes the biggest difference in practice. Without it, you're checking the output folder manually. With it, you get a message when each render finishes — or when one fails — wherever you are. Setup takes about two minutes: create an Incoming Webhook in your Slack workspace, paste the URL into the script, done.
I get a notification for every completed file, plus a separate message for failures that tells me the filename. When I wake up and there are twelve green ticks and one red cross, I know exactly what to look at.
Handling failures properly
The script moves failed source files to an error folder and writes ffmpeg's stderr output to a log file with the same name. This means you can diagnose what went wrong without re-running anything — just open the log. The most common failures I see are codec incompatibilities (the source file has a format ffmpeg doesn't like), disk space issues, and permission errors on the output directory. The log tells you which one it is.
time.sleep(2) before the render call is there because files don't always finish writing to disk before the directory watcher picks them up. If you're copying large source files over a network, increase that to 10 or 15 seconds. Without it, ffmpeg occasionally tries to read a file that's still being written and fails immediately.
Adding presets
The version above uses a single ffmpeg preset. In practice, I have three or four depending on the job: a ProRes 4444 master preset, an H.264 delivery preset with loudness normalisation, and an audio-only preset for mix exports. I route them based on the source file name — anything with _MASTER in the name gets the ProRes preset, anything with _DELIVERY gets H.264, and so on.
You can extend the script with a simple filename-matching block before the render call. It's ten more lines and makes the system significantly more useful without adding meaningful complexity.
What this doesn't replace
It's worth being clear about what this setup isn't. It's not a replacement for a proper render manager on a larger facility job. It doesn't handle distributed encoding across multiple machines, it doesn't have a web UI, and it doesn't do anything clever with priority queues. If you need that, look at something like pytranscoder or a full render farm setup.
But for solo work or a small team where you're doing your own delivery and you want renders to run unattended, this is the right level of complexity. It takes an hour to set up, you run it once, and then it just works.
The single biggest quality-of-life change in my workflow over the last two years wasn't a new piece of software or a faster machine. It was stops sitting in front of renders. Drop the files, close the lid, get on with your life. The script handles the rest.