

inotifywait, ffmpeg, ffprobe, and systemd, because those are easy to reason about and easy to replace later with a queue, API, or orchestration layer.
Decide what kind of transcode server you're building
Don't start by asking “CPU or GPU?” Start by defining the server’s normal job. Most post teams fall into one of these buckets:- Editorial proxy generation from camera originals or mezzanine media
- Review file generation from exports or graded rough cuts
- Standardization of mixed incoming media before edit
- Web or streaming output generation
- Archive normalization or rewrapping
- Transcode only streams that need to change.
- Stream copy everything else when the output target allows it.
- Use explicit stream mapping instead of trusting defaults.
- Preserve source identifiers needed for conform whenever proxies are involved.
Pick hardware for the bottleneck you actually have
FFmpeg can use CPU and, depending on your build and hardware, GPU acceleration. But “GPU transcode server” isn't automatically better than “CPU transcode server.” CPU encoding is the safest default for a post facility. It's predictable, it's flexible, and it supports a wide range of codecs and filters. More cores generally mean more parallel work, especially when you're encoding several files at once. GPU encoding is useful when you need high throughput for supported codecs like H.264 or H.265, and when you can accept the quality, compatibility, and driver constraints of the hardware encoder. A practical sizing model looks like this:- CPU: prioritize core count for parallel jobs, but keep per-core performance in mind for heavy filters and codecs.
- RAM: 16 GB is comfortable for a small server, 32 GB or more is safer for multiple concurrent transcodes and large files.
- Storage: use fast local SSD or NVMe for working directories, not only for the OS.
- Network: 10 GbE matters quickly if camera originals live on shared storage.
- GPU: useful for high-volume H.264/H.265 outputs, less useful if your bottleneck is disk, network, ProRes encoding, or color-managed processing.
Use a folder structure that prevents duplicate and partial jobs
The biggest watchfolder mistake is treating “file exists” as “file is ready.” When someone copies a 200 GB camera file into a shared folder, the watcher may see the filename long before the copy is complete. If FFmpeg starts reading too early, you get corrupt outputs, failed jobs, or partial transcodes that look valid until someone hits the bad section. Give every job state its own directory, so a file's location tells you what happened to it.
/transcode
/watch
/processing
/done
/failed
/output
/logs
The preferred ingest pattern is atomic move: copy the file somewhere temporary, then move it into /watch only after the copy is complete. On the same filesystem, a move is effectively instant, so the watcher sees a complete file rather than a growing one.
If users or other systems will copy directly into the watchfolder, add a stability check before processing. The script can compare file size twice with a delay and only continue when the size is unchanged.
Example stability function:
wait_until_stable() {
local file="$1"
local delay="${2:-20}"
while true; do
size1=$(stat -c%s "$file" 2>/dev/null || echo 0)
sleep "$delay"
size2=$(stat -c%s "$file" 2>/dev/null || echo 0)
if [ "$size1" -eq "$size2" ] && [ "$size1" -gt 0 ]; then
break
fi
done
}
That doesn't prove the file is perfect, but it prevents the most common partial-read failure. For camera cards and folder-based formats, watch for a sidecar marker file instead of individual media files. Your copy tool can write CARD_001.complete after the whole card is verified, and the watcher triggers from that marker.
Install FFmpeg with the codecs you actually need
The FFmpeg command line is only as capable as the build behind it. Native decoders are commonly available, but some encoders and hardware features require external libraries or build-time options. Before you automate anything, confirm that the server’s FFmpeg build supports your required codecs. Useful inspection commands:ffmpeg -version
ffmpeg -encoders
ffmpeg -decoders
ffmpeg -hwaccels
ffmpeg -filters
Also use ffprobe on real source files. It reports:
- Container, streams, and codecs
- Timecode-related tags and audio layouts
- Frame rate, duration, and other details
ffprobe -hide_banner -show_format -show_streams "input.mov"
For scripts, JSON output is easier to parse:
ffprobe -v error -print_format json -show_format -show_streams "input.mov"
Don't skip this part. FFmpeg’s automatic stream selection is convenient for one-off jobs, but automated commands should be explicit. Media files can contain multiple video streams, audio stems, and subtitles. Timecode tracks, attachments, and data streams turn up as well. Container formats also have limits on what stream types they can hold. A command that works on yesterday’s file may silently choose the wrong stream tomorrow unless you map streams deliberately.
Build the FFmpeg commands around output targets
The cleanest watchfolder setup has named presets, even if they're just shell functions or separate scripts. Avoid one giant command with a dozen conditional branches unless you enjoy debugging at 2 a.m.
| Output target | Typical codec or wrapper | Preset priorities | Watch out for |
|---|---|---|---|
| Editorial proxies | ProRes Proxy MOV, DNxHR LB, sometimes H.264 | Relink safety, source timecode, reel or clip names, edit performance | Changing frame rate, losing metadata, collapsing useful audio channels |
| Review files | H.264 MP4, H.265 MP4 where approved | Broad playback compatibility, reasonable file size, AAC audio, fast start | H.265 compatibility, aggressive compression, hardware encoder quality differences |
| Mezzanine transcodes | ProRes 422 HQ, ProRes 4444, DNxHR HQX, image sequences | Bit depth, color tags, alpha support, audio fidelity, predictable wrapper | Unintended pixel format conversion, incorrect color metadata, channel layout changes |
| Standardization before edit | Project-approved MOV or MXF codec, PCM audio | Consistent container, stream layout, frame size, and naming | Accidentally transcoding when rewrap or stream copy would be safer |
| Web or streaming outputs | H.264 or H.265 ladder, AAC audio | Target bitrate, resolution ladder, player requirements, upload size | Variable frame rate sources, audio drift, unsupported profiles or levels |
ffmpeg -hide_banner -y \
-i "$INPUT" \
-map 0:v:0 -map 0:a? \
-c:v prores_ks \
-profile:v 0 \
-vf "scale=1280:-2" \
-vendor apl0 \
-c:a pcm_s16le \
-movflags +faststart \
"$OUTPUT"
For review MP4s, compatibility matters more than edit performance. H.264 with AAC audio is still the safest broad target.
Example review command:
ffmpeg -hide_banner -y \
-i "$INPUT" \
-map 0:v:0 -map 0:a:0? \
-c:v libx264 \
-preset medium \
-crf 20 \
-pix_fmt yuv420p \
-vf "scale='min(1920,iw)':-2" \
-c:a aac \
-b:a 192k \
-movflags +faststart \
"$OUTPUT"
For mezzanine transcodes, be conservative. Know whether you need 10-bit, alpha, or timecode, and whether the delivery calls for specific color tags or a particular wrapper. Don't collapse audio channels or convert pixel formats casually.
Example ProRes 422 HQ command:
ffmpeg -hide_banner -y \
-i "$INPUT" \
-map 0:v:0 -map 0:a? \
-c:v prores_ks \
-profile:v 3 \
-pix_fmt yuv422p10le \
-c:a pcm_s24le \
"$OUTPUT"
For GPU-assisted H.264 review outputs, test quality and compatibility before using it everywhere. Hardware encoders can be fast, but they have their own options and limitations.
Example NVIDIA-style command:
ffmpeg -hide_banner -y \
-hwaccel cuda \
-i "$INPUT" \
-map 0:v:0 -map 0:a:0? \
-c:v h264_nvenc \
-preset p5 \
-cq 23 \
-vf "scale=1920:-2" \
-c:a aac \
-b:a 192k \
-movflags +faststart \
"$OUTPUT"
These are starting points. Base your real presets on the NLE and finishing path, on the delivery specs and storage limits, and on the source media you actually receive.
Write a watcher that can survive normal post chaos
On Linux,inotifywait is a practical way to trigger jobs when files arrive. Use close_write or moved-file events rather than a simple polling loop when possible.
Here is a basic watcher:
#!/usr/bin/env bash
set -u
WATCH_DIR="/transcode/watch"
PROCESSING_DIR="/transcode/processing"
DONE_DIR="/transcode/done"
FAILED_DIR="/transcode/failed"
OUTPUT_DIR="/transcode/output"
LOG_DIR="/transcode/logs"
mkdir -p "$WATCH_DIR" "$PROCESSING_DIR" "$DONE_DIR" "$FAILED_DIR" "$OUTPUT_DIR" "$LOG_DIR"
inotifywait -m -e close_write -e moved_to --format '%w%f' "$WATCH_DIR" | while read -r FILE
do
[ -f "$FILE" ] || continue
BASENAME=$(basename "$FILE")
STEM="${BASENAME%.*}"
JOB_ID="$(date +%Y%m%d-%H%M%S)-$STEM"
LOG_FILE="$LOG_DIR/$JOB_ID.log"
echo "Starting job: $FILE" >> "$LOG_FILE"
wait_until_stable "$FILE" 20
INPUT="$PROCESSING_DIR/$BASENAME"
OUTPUT="$OUTPUT_DIR/${STEM}_review.mp4"
mv "$FILE" "$INPUT"
ffmpeg -hide_banner -nostdin -y \
-i "$INPUT" \
-map 0:v:0 -map 0:a:0? \
-c:v libx264 \
-preset medium \
-crf 20 \
-pix_fmt yuv420p \
-vf "scale='min(1920,iw)':-2" \
-c:a aac \
-b:a 192k \
-movflags +faststart \
"$OUTPUT" >> "$LOG_FILE" 2>&1
STATUS=$?
if [ "$STATUS" -eq 0 ]; then
echo "Job succeeded: $OUTPUT" >> "$LOG_FILE"
mv "$INPUT" "$DONE_DIR/$BASENAME"
else
echo "Job failed with status $STATUS" >> "$LOG_FILE"
mv "$INPUT" "$FAILED_DIR/$BASENAME"
fi
done
A few details matter here.
The -nostdin flag is important when FFmpeg runs in the background or under a service manager. FFmpeg normally listens for console input, such as q to quit. In unattended scripts, you don't want FFmpeg trying to interact with a terminal that isn't really there.
The script moves files into /processing before running FFmpeg. That keeps the watchfolder clean and prevents reprocessing if the watcher restarts.
The command redirects both standard output and standard error into a per-job log. FFmpeg logs to stderr by default, so capturing only stdout will miss the useful information.
This example processes one job at a time, which is intentional for a first version. Parallel processing is useful, but only after you know the server’s real throughput and bottlenecks.
Run the watcher as a service
A watchfolder is only useful if it keeps running after you close your SSH session. On Linux, usesystemd for a simple always-on service.
Example unit file:
[Unit]
Description=FFmpeg Watchfolder Transcode Service
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=transcode
Group=transcode
ExecStart=/usr/local/bin/watch-transcode.sh
Restart=always
RestartSec=5
WorkingDirectory=/transcode
Environment=AV_LOG_FORCE_NOCOLOR=1
[Install]
WantedBy=multi-user.target
Enable it with:
systemctl daemon-reload
systemctl enable ffmpeg-watchfolder
systemctl start ffmpeg-watchfolder
Then inspect it with:
systemctl status ffmpeg-watchfolder
journalctl -u ffmpeg-watchfolder -f
Run the service as a dedicated user, not as root. Give that user read/write permissions only where needed: the watch, processing, and output directories, plus logs, done, and failed. If the server reads from shared storage, make sure it mounts before the service starts. Network disconnects should not leave jobs half-running.
Log enough to debug without watching the terminal
You need two levels of logging: service logs and job logs. Service logs tell you whether the watcher is alive, whether directories are mounted, and whether the script is crashing. Job logs tell you which FFmpeg command ran, what FFmpeg detected, and why a file failed. For routine operation,-hide_banner keeps logs readable. For failures, FFmpeg’s -loglevel option lets you control verbosity. Common values include error, warning, info, and debug. You can also use FFmpeg’s report feature to dump command-line and log output to a timestamped report file, which is useful when chasing strange decode or muxing issues.
Include these fields in a practical job log:
- Job ID
- Input path
- Output path
- Preset name
- Full FFmpeg command
- FFmpeg exit code
- Start and end timestamps
ffprobesummary for the input- Final output file size
- Any move to
doneorfailed

Handle errors as workflow states
Most watchfolder failures are predictable, so build for them. Common failure modes include:- File copied before it was complete
- Unsupported codec or missing FFmpeg library
- Invalid stream mapping because expected audio or video is missing
- Not enough disk space
- Corrupt source media
-map 0:a? makes audio optional, which is useful for silent graphics exports. But for camera proxy generation, missing audio may be a real problem. Make the preset reflect the workflow expectation.
Before the encode, use ffprobe to reject files that clearly don't match the preset. If a review preset requires one video stream, check for it. If an editorial proxy preset requires timecode metadata, log whether it exists. This avoids producing “successful” outputs that fail later in edit.
Monitor load before adding concurrency
Once one-at-a-time processing is stable, measure, then decide whether to run multiple jobs in parallel. Watch these signals:- CPU usage and load average
- GPU encoder and decoder usage, if applicable
- RAM and swap
- Disk read/write throughput
- Free disk space in processing and output volumes
- Network throughput to shared storage
- Job duration by preset
- Queue depth or number of files waiting
- Failure rate by source type and preset
top, htop, iostat, df, du, and nvidia-smi can tell you a lot. On a facility server, push those metrics into a monitoring system so post supervisors can see whether the box is idle, busy, or stuck.
Don't assume 16 cores means you should run 16 FFmpeg jobs. Encoding can saturate memory bandwidth, storage, or the network before CPU reaches 100 percent, and a hardware encoder can hit its own ceiling first. Start with two concurrent jobs, measure, then increase. For proxy generation from shared storage, the network or source volume may become the bottleneck before the transcode server does.
If you need proper concurrency, add a queue rather than launching unlimited background jobs from the watcher. Even a simple spool directory with a worker limit is better than letting every file start immediately.
Keep presets predictable and documented
The server will become part of the facility’s workflow. Give presets names people understand. Good preset names describe the intended use:editorial_prores_proxy_720peditorial_dnxhr_lb_1080preview_h264_1080pmezzanine_prores422hqaudio_wav_48k
- One camera file with timecode, and one mixed-audio file
- One no-audio file, and one weird phone file
- One long duration file, and one intentionally bad file
When to move beyond a shell-script watchfolder
A shell-script watchfolder is a good first server. It's transparent, cheap, and easy to debug, but it has limits. You may want a more formal job system when:- Multiple users submit jobs from different locations
- You need authentication or role-based access
- Several servers should share one queue
- Failed jobs need retry rules
- You need audit history across productions
The build path
The fastest reliable path is to start small and keep each layer predictable.- Set up the server with fast local working storage and enough CPU for your expected daily load.
- Install an FFmpeg build that supports your codecs.
- Create a watchfolder structure that separates incoming, processing, completed, failed, output, and logs.
- Use atomic moves or file stability checks so you don't process partial copies.
- Start with one or two presets that match real workflow targets.
- Use explicit
-mapoptions. - Capture per-job logs.
- Run the watcher under
systemd. - Measure load before adding concurrency.
FAQ
Use CPU encoding when you need broad codec support, predictable quality, complex filters, or mezzanine and editorial proxy workflows. Use GPU encoding when the main requirement is high-volume H.264 or H.265 output and you have tested the quality, driver stability, and compatibility of the hardware encoder with your real source media.
The safest method is to copy files to a temporary location first, then move them into the watchfolder only after the copy is complete. If users copy directly into the watchfolder, add a stability check that waits until the file size stops changing before FFmpeg starts. For camera cards or folder-based media, trigger from a marker file such as CARD_001.complete after the full card has been verified.
For editorial proxies, common choices include ProRes Proxy, DNxHR LB, or another edit-friendly codec that preserves relink-critical metadata and performs well in the NLE. For review files, H.264 MP4 with AAC audio is still the safest broad compatibility target. Base the preset on your edit system and finishing path, on your storage limits and upload bandwidth, and on the source media you actually receive.
Start with one job at a time, measure performance, then increase gradually. Multiple concurrent jobs can saturate CPU, storage, network, memory bandwidth, or a GPU hardware encoder before the server appears fully loaded. A queue or worker limit is safer than launching every incoming file immediately.
Log the job ID and preset name, the input and output paths, and the full FFmpeg command. Log the start and end timestamps, the FFmpeg exit code, an ffprobe summary, the final output size, and whether the source was moved to done or failed. Failed jobs should keep the source file and log so an assistant can retry, use a different preset, or escalate the issue.
Use clear version naming in the output path, write the preset and source filename into the job log, and avoid sending review files as loose attachments. Aspect keeps revisions together with version stacking, so notes and approvals stay connected to the current cut instead of drifting across separate exports.





