Tell transQrate how good the result should look — it finds the encoder settings. Instead of guessing quality values, you set a VMAF target (e.g. 95) per media folder: transQrate cuts short sample clips from every file, encodes them at candidate quality levels, scores them with libvmaf and binary-searches the highest ICQ — the smallest file — that still hits your target. The ab-av1 idea, built into a self-hosted transcoding server.
Under the hood: Intel QSV hardware AV1 encoding, Opus audio and a slim web UI — inspired by Unmanic, FileFlows, Tdarr and ab-av1.
Everything runs in a single Docker container built on
linuxserver.io's ffmpeg image
(ffmpeg 8.x with av1_qsv, libvmaf, libopus and the Intel media stack
prebuilt — nothing is compiled from source).
- VMAF matching (à la ab-av1): set a VMAF target instead of a fixed quality — every file gets exactly the quality you asked for, at the smallest size the encoder can deliver
- Slim web UI (no build step, no external assets) with dashboard, sources, profiles, logs and settings pages
- Source folders: assign a transcoding profile per folder, hit Scan now, or enable Watch to queue new files automatically (files are only queued once their size is stable between two scans)
- Transcoding profiles: predefined
AV1 QSV + Opus (ICQ 22)— AV1 viaav1_qsvin ICQ mode, audio in Opus keeping the source channel count at 64 kbps per channel. Create as many profiles as you like (fixed ICQ or VMAF target, preset, audio copy/re-encode, container, extra ffmpeg args) - Dashboard: live progress (%, fps, speed, ETA), queue, per-file and total saved space, retry/cancel, deletable history (entries and their logs)
- Logs in the UI: full ffmpeg output per job plus the application log
- Finished files are tagged with a
TRANSQRATEmetadata tag (visible viaffprobe), so they are recognized and never transcoded twice — even after moving them around - SQLite for all state (profiles, sources, jobs, settings) in
/config
# docker-compose.yml
services:
transqrate:
image: ghcr.io/dermute/transqrate:latest
container_name: transqrate
ports:
- "8585:8585"
devices:
- /dev/dri:/dev/dri # Intel GPU for QSV
volumes:
- ./config:/config # database, logs, temp files
- /path/to/media:/media/library # a source folder
- /path/to/transcoded:/output # optional output folder
environment:
- PUID=1000 # files on the volumes are written as
- PGID=1000 # this user/group ...
- UMASK=022 # ... with these permissions
restart: unless-stoppeddocker compose up -dOpen http://localhost:8585, add /media/library as a source, pick a
profile, press Scan now.
The container starts as root (as Portainer & friends do), then drops to an unprivileged user before running the app — the familiar linuxserver.io mechanism, since the image is built on their s6-overlay base:
PUID/PGID(default911): everything the app writes to the volumes — transcoded files, database, logs — is owned by this uid/gid. Match them to the owner of your media folders (id youruser).UMASK(default022): permission mask for created files (022→644,002→ group-writable664)./configis chowned toPUID:PGIDon start; GPU access is set up automatically (the user is added to the group owning/dev/dri/*).
Don't add init: true / --init: the image's s6 init must be PID 1 and
already reaps ffmpeg children itself.
Mount every folder transQrate should see as a volume, then reference the container paths in the web UI. Each source folder has an optional output folder:
- No output folder — transcode in place: the finished file replaces the original (same location and name, container extension of the profile).
- With an output folder (e.g.
/output) — the source tree is mirrored there (/media/library/A/b.mkv→/output/A/b.mkv). The per-source "Delete original after transcoding" toggle (on by default for new sources) decides whether the source file is removed after a successful encode or kept.
AV1 hardware encoding (av1_qsv) needs an Intel GPU with an AV1 encoder:
Arc (DG2) discrete GPUs or Meteor Lake (Core Ultra) and newer iGPUs.
Older iGPUs (UHD 6xx/7xx) can decode AV1 but not encode it — use a profile
with hevc_qsv instead. The container needs access to /dev/dri.
Multiple GPUs (e.g. iGPU + Arc): map the render node you want at its
original path (renaming breaks libva's sysfs lookup) and point the
qsv_device setting (Settings page) at it, e.g. /dev/dri/renderD129.
For a profile in VMAF target mode, each file first goes through an
analyzing phase:
- 2–6 sample clips (20 s each, spread across the runtime) are extracted losslessly
- Samples are encoded with the profile's settings at a candidate ICQ and
compared against the source clips with
libvmaf - A binary search over the ICQ range (default 16–35) finds the highest ICQ whose mean sample VMAF still reaches the target — highest ICQ means smallest file
- The whole file is then encoded once with the chosen ICQ; the predicted VMAF and chosen ICQ are shown on the dashboard
Sample count, clip length and the ICQ search range are configurable on the Settings page. Cost: roughly 4–6 short sample encodes + VMAF runs per file.
If the profile downscales, the reference sample is passed through the
identical scale filter (pure filtering, no re-encode) so both sides are
compared at the output resolution — the score isolates codec fidelity
instead of punishing the intentional resolution change (same approach as
ab-av1's --reference-vfilter default).
The VMAF model is chosen automatically: comparisons at 4K (4K source,
no downscale) use Netflix's vmaf_4k_v0.6.1 — the default 1080p model
is several points too pessimistic on 4K frames. Everything at 1080p or
below (including downscaled targets) uses the standard model.
| Field | Meaning |
|---|---|
| Video codec | any ffmpeg encoder, default av1_qsv |
| Preset | veryfast … veryslow (QSV target usage) |
| Quality mode | Fixed ICQ or VMAF target |
| ICQ | QSV Intelligent Constant Quality, lower = better (default 22) |
| VMAF target | e.g. 95 — auto-derives the ICQ per file |
| Output resolution | keep source, or downscale to 480p/720p/1080p/4K (never upscales; width-based so scope content maps correctly) |
| Bit depth | keep source (default; HDR content stays 10-bit) or force 8-bit |
| Audio | Opus at N kbps × channel count (channels preserved), or copy |
| Audio channel limit | optional downmix cap, e.g. max 5.1 turns 7.1 into 5.1 but leaves stereo untouched |
| Container | mkv (recommended) or mp4 |
| Extra video args | appended verbatim, e.g. -look_ahead_depth 100 -extbrc 1 -adaptive_i 1 -adaptive_b 1 -g 240 (the default profiles' setting) |
Subtitles, chapters, metadata and (for mkv) font attachments are carried over;
mov_text subtitles are converted to SRT.
- In place vs. output folder: without an output folder the original is replaced (same name, profile's container extension). With an output folder the source tree is mirrored there; the per-source delete original toggle (default on for new sources) controls whether the source file is removed after a successful encode.
- Skip if larger: if the encode ends up bigger than the source, the result
is discarded and the original is moved (delete original on) or copied
(off) to the output folder, so the output tree always holds every
processed file (configurable via
skip_if_larger). - Tagging: outputs carry
TRANSQRATE=1,TRANSQRATE_PROFILE,TRANSQRATE_ICQandTRANSQRATE_DATEformat tags. - Watch: every
scan_interval_s(default 300 s) watched folders are scanned; unstable (still copying) files wait one more round. - HDR: with bit depth keep source, 10-bit and the PQ/HLG colorimetry survive the encode (HDR10-compatible AV1 output). HDR10+ and Dolby Vision dynamic metadata is not carried over.
- Crash safety: encodes write to hidden
.…tqtmp.…temp files that are atomically renamed on success and ignored by the scanner.
docker compose up --build # run
# API docs: http://localhost:8585/docsLayout: transqrate/ Python package (FastAPI app in main.py, workers in
worker.py, VMAF search in vmaf.py, scanner in scanner.py), vanilla-JS UI
in transqrate/static/. State lives in /config/transqrate.db; logs in
/config/logs/.
This project was developed with AI assistance (Anthropic Claude, Fable 5).
MIT — see LICENSE.