hvac-transcoder 5.4.0

GPU-accelerated media transcoder (HEVC/h265 via NVENC, VAAPI, VideoToolbox). Single-binary Tdarr alternative.
hvac-transcoder-5.4.0 is not a library.

HVAC — Get your TBs back

CI crates.io docs.rs License: MIT

Point hvac at a directory that contains videos — even ones hidden inside .img and .iso files — and it'll compress them to h.265 (HEVC) using reasonable defaults. You can overwrite these defaults with a small config file.

You need a GPU with an HEVC encoder (NVIDIA NVENC, Intel VAAPI, or Apple VideoToolbox) and an ffmpeg built against it. The installer below auto-installs ffmpeg on macOS, Debian/Ubuntu, Alpine, and OpenMediaVault, and prints platform-specific guidance on Synology, QNAP, and Unraid. For everything else there's Docker or the NAS-specific notes.


Install

Debian / Ubuntu

curl -fsSL https://jackdanger.github.io/HVAC/key.gpg \
  | sudo gpg --dearmor -o /etc/apt/keyrings/hvac.gpg
echo "deb [signed-by=/etc/apt/keyrings/hvac.gpg] https://jackdanger.github.io/HVAC stable main" \
  | sudo tee /etc/apt/sources.list.d/hvac.list
sudo apt update && sudo apt install hvac

Future releases arrive via sudo apt upgrade.

macOS

brew install JackDanger/tap/hvac

Future releases arrive via brew upgrade hvac.

All platforms

curl -fsSL https://raw.githubusercontent.com/JackDanger/hvac/main/install.sh | sh

The script uses Homebrew on macOS and the apt repository on Debian/Ubuntu; other Linux distros and NAS platforms get a pre-built tarball with platform-specific guidance.

Other ways: cargo install hvac-transcoder · AUR · tarballs · Docker · Synology / QNAP / Unraid


Use

First time on a library you care about, do a dry run:

hvac --dry-run /path/to/movies        # preview, change nothing

Once you've eyeballed the list, drop --dry-run:

hvac /path/to/movies                  # overwrite in place (default)
hvac --no-overwrite /path/to/movies   # write .transcoded.mkv copies, keep originals

It scans the directory, skips files that already meet the target, and re-encodes the rest. Re-running picks up where you left off — there's a sidecar .hvac.complete next to each output that records source size + duration so the next run knows whether to adopt it or re-encode. Ctrl-C is safe; in-progress encodes leave a .hvac_tmp_* file that the next run sweeps.


Disc images (.iso / .img)

When the input is a Blu-ray or DVD image, hvac analyses the disc structure, picks the main feature (largest m2ts run for Blu-ray, largest VTS for DVD; multi-title DVDs become one output per title), and pipes the streams into ffmpeg without ever extracting to a temp directory.

A disc usually carries several audio tracks — the primary mix, a commentary, sometimes a dub or audio description. hvac selects exactly one as the output audio. The picker (see pick_primary_audio in src/probe.rs):

  1. Drops tracks flagged as commentary (by disposition.comment or by title keyword — "Commentary", "Director's", "Audio description", etc.).
  2. Among what's left, prefers the track with the most channels (a 5.1 surround mix beats a 2-channel commentary on any modern release).
  3. Tiebreaks on bitrate, then the muxer's default flag, then stream index.

Year-aware flip. When the disc image filename carries a release year earlier than 1955 — pre-stereo cinema — the channel preference inverts: a 1-channel mono original beats a 2-channel commentary. The year is parsed from the filename (The Maltese Falcon (1941).iso, Movie.Title.1933.iso), falling back to non-commentary track titles when the filename is opaque (e.g. a raw BDMV_DISC.iso).

Ambiguous selections. Some discs have a genuine coin flip — two AC3 2.0 192k tracks on a DVD with no disposition flag set, or every track flagged as commentary. By default hvac picks one anyway and logs a WARN. To skip those discs instead, opt in:

hvac --skip-ambiguous-audio /path/to/movies

or, persistently, in config.yaml:

skip_ambiguous_audio: true

Either source enables it (CLI on or config on → skip). Skipped discs print one line each to stderr with the reason so you can review them by hand.


Does it actually save space?

Real numbers from one library — public domain films, full bitrate range from pristine remuxes to lo-fi early transfers:

File Before After Savings
Nosferatu (1922) Remux-1080p.mkv 27.2 GB 4.1 GB -84%
The Blood of a Poet (1932) Bluray-1080p.mkv 3.3 GB 1.0 GB -69%
Battleship Potemkin (1925) Remux-1080p.mkv 21.0 GB 3.5 GB -83%
Sherlock Jr. (1924) Remux-1080p.mkv 22.7 GB 4.6 GB -79%
Way Down East (1920) Remux-1080p.mkv 17.5 GB 3.2 GB -81%
The Black Pirate (1926) Remux-1080p.mkv 21.1 GB 4.7 GB -77%
Intolerance (1916) Bluray-1080p.mkv 13.9 GB 3.2 GB -77%
The Gold Rush (1925) Bluray-1080p.mkv 15.0 GB 4.1 GB -72%
Metropolis (1927) Remux-1080p.mkv 798 MB 120 MB -84%
Our Hospitality (1923) Bluray-1080p.mkv 11.4 GB 4.2 GB -63%
The Boat (1921) Bluray-1080p.mkv 2.2 GB 839 MB -62%
Safety Last! (1923) WEBDL-1080p.mkv 6.1 GB 3.1 GB -49%
The Phantom of the Opera (1925) Bluray-1080p.mkv 4.8 GB 2.5 GB -48%
The Blacksmith (1922) Bluray-1080p.mkv 1.5 GB 670 MB -55%
The Blot (1921) Bluray-1080p.mkv 3.9 GB 2.5 GB -34%
The General (1926) Bluray-1080p.mkv 4.9 GB 3.4 GB -31%
The Navigator (1924) Bluray-1080p.avi 700 MB 406 MB -42%
The Kid (1921) Remux-1080p.mkv 5.4 GB 1.1 GB -80%
Strike (1925) Bluray-1080p.mkv 5.3 GB 3.3 GB -37%

Average across the full library: ~65% smaller.


GPU required

GPU Encoder Platform
NVIDIA (Kepler+) hevc_nvenc Linux
Intel (Broadwell+) hevc_vaapi Linux
Apple Silicon / Intel Mac hevc_videotoolbox macOS

No GPU, no go — hvac exits with a clear message. CPU h265 is too slow to be worth shipping.


Config

The defaults are sensible. To tune quality, presets, max resolution, etc.:

hvac --dump-config > config.yaml
$EDITOR config.yaml
hvac --config config.yaml /path/to/movies

Docker

If you'd rather not install ffmpeg + drivers + the binary on the host — or if the host is a NAS where those don't behave — there's a container image with everything pre-wired.

# Intel iGPU (Broadwell+)
docker run --rm \
  --device /dev/dri:/dev/dri \
  --user "$(id -u):$(id -g)" \
  -v /path/to/media:/media \
  ghcr.io/jackdanger/hvac:latest --dry-run /media

# NVIDIA (needs nvidia-container-toolkit on the host)
docker run --rm \
  --gpus all --runtime=nvidia \
  --user "$(id -u):$(id -g)" \
  -v /path/to/media:/media \
  ghcr.io/jackdanger/hvac:latest --dry-run /media

The image ships configured to run as UID 1026 GID 100 — Synology / Unraid / OMV's default admin user. On a regular Linux host that UID probably doesn't own your media, so the --user "$(id -u):$(id -g)" above remaps the container's user to yours; drop the flag if you're on a NAS and your admin account already matches 1026:100.

For compose, copy compose.example.yml and edit the volume path. The image is built and published to GHCR by the docker.yml workflow on every push to main; until the first push lands you can build it locally from this repo's Dockerfile:

docker build -t hvac .
docker run --rm --device /dev/dri:/dev/dri -v /path/to/media:/media \
  hvac --dry-run /media

NAS-specific instructions (Synology Container Manager, QNAP Container Station, Unraid Community Applications, TrueNAS SCALE, OpenMediaVault) live in docs/NAS.md. If your NAS has no GPU, that doc also covers the "mount over NFS and transcode off-box" pattern.


Troubleshooting

"No GPU found for h265 encoding!"

  • macOS: nothing to do — Apple Silicon and all post-2017 Macs have VideoToolbox built in. If you still see this, your shell is missing ffmpeg; brew install ffmpeg.
  • Linux + Intel iGPU: ls -la /dev/dri — if renderD128 isn't there, load the driver (sudo modprobe i915 on most distros) and install intel-media-va-driver + vainfo.
  • Linux + NVIDIA: nvidia-smi should print your card. If it doesn't, install the proprietary driver and reboot. The open-source nouveau driver has no NVENC.
  • Docker / NAS: pass the device. --device /dev/dri:/dev/dri for Intel; --gpus all --runtime=nvidia for NVIDIA. See docs/NAS.md.

"Can I do CPU encoding instead?" No, by design. x265 at the quality the defaults target runs at ~5 fps on a fast desktop CPU. A 2-hour movie is 6+ hours of wall time vs. 5 minutes on a $50 used Quadro. If you're on a NAS without a GPU, see docs/NAS.md for the off-box pattern.

"Will it touch my files?" It overwrites by default — only after the new encode has passed an ffprobe duration + codec + min-size check, and only via an atomic rename of a .hvac_tmp_… sidecar over the original. The first run on a library you care about should be hvac --dry-run, then hvac --no-overwrite; the latter writes .transcoded.<ext> copies you can compare before committing with --replace.

"It's stuck on a single file." ffprobe has a watchdog (--probe-timeout, default 30 s); the directory walk doesn't. If your media lives on a flaky NFS / SMB mount and the scan hangs, that hang is on the kernel's mount layer, not hvac. Raise the probe timeout on slow NAS shares: hvac --probe-timeout 120 /path.

"I want to stop it cleanly." Ctrl-C once — workers finish their current file, then exit. Ctrl-C twice — force quit; in-progress .hvac_tmp_* files are swept on the next run. Resume is automatic.


Controlling resource usage during multi-day transcodes

For long-running batch transcodes (a media library of thousands of files takes days) you may want a way to observe progress, push tuning changes, or kill the process from outside without losing partial work. hvac integrates with LaunchDarkly to support that — the binary connects to your project at startup if you pass an SDK key:

Full disclosure: At the time of writing, I work at LaunchDarkly. I drive my whole homelab config with it.

  1. Provision the LaunchDarkly project once: hvac --setup-launchdarkly --ld-api-key <YOUR_LD_API_KEY>
  2. Note the SDK key it prints. Pass it on each long-running invocation:
    hvac --launchdarkly-sdk-key <SDK_KEY> /path/to/media
    
  3. With a key supplied, hvac connects to LaunchDarkly's evaluation endpoint and exports per-encode OpenTelemetry spans to LaunchDarkly Observability, so you can watch live progress and timing in the LD dashboard.

The three flags that are active during a run:

Flag Type Effect
pause-transcoding boolean Workers finish their current file, then spin until you set it back to false
enable-transcoding boolean Kill-switch — workers stop picking up new files when set to false
max-parallel-jobs integer Override the parallel encoder count on the fly (0 = auto)

The SDK key is CLI-only by design — it does not read from any environment variable. This is deliberate: hvac controls expensive GPU/disk resources, and a key that lives in your shell rc would silently apply to every run. Keep the key in a secure location and pass it explicitly when you want remote observability/control to be active.


Development

Hook the lint checks up once per clone:

git config core.hooksPath .githooks

After that, every commit that touches a .rs file runs:

  1. cargo fmt --all -- --check — sub-second; the commit is rejected if any file would be reformatted. Fix with cargo fmt --all.
  2. cargo clippy -- -D warnings — slower (5-30s cold, <2s incremental); rejected if any lint fires. Both checks mirror exactly what CI enforces.

To bypass the slow check for a quick fix-up commit (you've already run clippy yourself or are about to squash anyway): HVAC_SKIP_CLIPPY=1 git commit .... To bypass both: git commit --no-verify.

For more on the code layout, retry tiers, and concurrency model, see docs/ARCHITECTURE.md.


Contributing

Pull requests welcome — see CONTRIBUTING.md for the PR checklist, code style notes, and release flow. Bugs go on the issues tracker; security-sensitive reports go to the address in SECURITY.md instead.

The CHANGELOG.md tracks user-visible changes in Keep a Changelog format.


License

MIT © Jack Danger