Pelorus filters are libavfilter Vulkan filters: they consume and produce
AV_PIX_FMT_VULKAN frames. The win comes from keeping frames in VRAM from
decode to encode, so a recipe for a hardware encoder never contains
hwdownload. hwdownload stays only in recipes for software encoders
(libaom, SVT-AV1, x265), where it is inherent: those encoders read system
memory. scripts/check-doc-recipes.py enforces both rules on every recipe in
this repository.
Codec-agnostic. The filters pre-process pixels; the encoder is your choice, but FFmpeg hardware-frame domains must match, and each encoder family has its own boundary:
| Encoder | Boundary from AV_PIX_FMT_VULKAN |
Copy | Section |
|---|---|---|---|
Vulkan Video (*_vulkan) |
none: same frames context | none | Full zero-copy |
| NVENC | derive Vulkan from CUDA device (vulkan=vk@cu), hwupload to CUDA |
VRAM to VRAM copy | NVENC |
| VAAPI, QSV (Linux, Intel) | tiling=drm on the last writing filter, then hwmap |
none (map) | VAAPI and QSV |
| AMF | none exists in FFmpeg n9.0.2 | not available | AMF |
| libaom, SVT-AV1, x265 | hwdownload |
host round trip, inherent | Software encoders |
Every recipe on this page is one of three kinds. "Verified" means it ran on
the named GPU with the named binary, ended with exit code 0 and the log shows
the claimed path (no hwdownload, no auto-inserted scale, the encoder taking
a hardware frames context). Anything else says so next to the recipe.
| Recipe | GPU | Status |
|---|---|---|
| NVENC through the CUDA hop (derived Vulkan device) | RTX 4090, driver 615.78.08 | verified behind NVDEC for all 9 measured filter chains on the derived device; separate Vulkan device fails with CUDA_ERROR_INVALID_VALUE (#296, ADR-0191) |
VAAPI and QSV through tiling=drm, NV12 |
Arc A380, iHD, xe |
verified |
| Vulkan decode to Vulkan encode | RTX 4090 | verified for hevc_vulkan with the graphs listed below; the AV1 variant not run |
| P010 into VAAPI and QSV | any | not working: stock FFmpeg defect (Vulkan output pools) |
tiling=drm on AMD |
Ryzen iGPU, RADV | maps, but frames can be read unfinished; not a working recipe |
| AMF | none | not run on hardware: no AMF runtime on the Linux test host, and FFmpeg has no Vulkan-to-AMF path |
| libaom, SVT-AV1, x265 recipes | none | not re-run on hardware: the test binary has none of these encoders; the graph shape is the same as the verified ones |
The verified runs used an FFmpeg n9.0.2 build with the shared fix series
(patches 0001 to 0004) and the Pelorus patch stack at 50b625b, on
120-frame clips (3000 frames where stated) at 1280x720, 8-bit unless a recipe
says 10-bit. Host: NVIDIA driver 615.78.08, Linux 7.2.9, Arc A380 on
the xe kernel driver with iHD.
FFmpeg n9.0.2 has no Vulkan-to-NVENC map. The two hops between CUDA and
Vulkan are hwupload calls that copy within VRAM; no frame reaches system
memory in the NVDEC variant; the software-decode variant uploads the decoded
frames from system memory once (inherent to a software decoder) and downloads
nothing.
To avoid creating two separate CUDA contexts, derive the Vulkan device from
the decoder's CUDA device (vulkan=vk@cu). In stock FFmpeg
(libavutil/hwcontext_vulkan.c vulkan_export_to_cuda()), a Vulkan frame's
memory and external semaphores are imported into CUDA once and cached. With
separate devices (-init_hw_device vulkan=vk:0,disable_multiplane=1),
hwupload=derive_device=cuda creates a second CUDA context with foreign
semaphores, causing cuWaitExternalSemaphoresAsync to fail with
CUDA_ERROR_INVALID_VALUE (exit 187; #296, ADR-0191).
With the Vulkan device derived from cu, hwupload=derive_device=cuda returns
cu itself, so both hops use one CUDA context; every measured chain passes (table
below). A Vulkan device derived from CUDA is always created with multiplane
disabled (hwcontext_vulkan.c), so this form needs no disable_multiplane=1:
ffmpeg -init_hw_device cuda=cu:0 -init_hw_device vulkan=vk@cu -filter_hw_device vk \
-hwaccel cuda -hwaccel_device cu -hwaccel_output_format cuda -i input.mkv \
-vf "hwupload,pelorus_deband_vulkan=range=15:dither=bluenoise:dynamic=1,hwupload=derive_device=cuda" \
-c:v hevc_nvenc -cq 28 out.mkv # or av1_nvenc / h264_nvencVerified on an RTX 4090: exit code 0, 120 frames; the log shows pixfmt:cuda
at the graph input, Transferred CUDA image to Vulkan!, Transferred Vulkan image to CUDA! and Using input frames context (format cuda) with hevc_nvenc encoder.
A software decoder works with the separate Vulkan device, because the decoded frames never enter a CUDA context before the second hop:
ffmpeg -init_hw_device vulkan=vk:0,disable_multiplane=1 -filter_hw_device vk -i input.mkv \
-vf "format=nv12,hwupload,<filters>,hwupload=derive_device=cuda" \
-c:v hevc_nvenc -cq 28 out.mkvVerified on an RTX 4090 with this separate-device form, exit code 0, no CUDA
error, 8-bit input: the filters of every chain in the table below, 120 frames each; deband twice
and pelorus_mc_vulkan=meta=1,pelorus_denoise_vulkan=prev=3:mc=1 also at 3000
frames. The separate device needs disable_multiplane=1; without it the command
fails with Cannot map a multiplane Vulkan image (1 image(s) for 2 plane(s)) to CUDA (exit code 218). Not measured: software decode on the derived device, and
10-bit input on either form.
Behind NVDEC on an RTX 4090 with a 120-frame 8-bit HEVC clip into hevc_nvenc
(driver 615.78.08, stack build 50b625b), the separate device setup fails while
the derived device passes all 120 frames on all 9 measured chains:
Filter chain between the two hwupload calls |
Separate Vulkan device (vulkan=vk:0,disable_multiplane=1) |
Derived Vulkan device (vulkan=vk@cu) |
|---|---|---|
null |
fails at frame 0 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_analyze_vulkan |
fails at frame 0 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_mc_vulkan=meta=1 |
fails at frame 0 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_scenecut |
fails at frame 0 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_denoise_vulkan=prev=3 |
fails at frame 33 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_mc_vulkan=meta=1,pelorus_denoise_vulkan=prev=3:mc=1 |
fails at frame 33 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_borderfix_vulkan=left=4:right=4:planes=0x1,pelorus_borderfix_vulkan=left=2:right=2:planes=0xE |
fails at frame 32 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_deband_vulkan,pelorus_deband_vulkan |
fails at frame 32 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
pelorus_grain_estimate_vulkan=strength=2.0,pelorus_denoise_vulkan=strength=0.4 |
fails at frame 33 (CUDA_ERROR_INVALID_VALUE, exit 187) |
passes (120 frames) |
Behind NVDEC on the separate device, a single Pelorus writing filter (deband,
aa, mc + deband) passed 3000 frames, but a single stock gblur_vulkan fails at
frame 33, so the old one-writing-filter rule is not a safe guide. The defect is in
stock FFmpeg (libavutil/hwcontext_vulkan.c) and reproduces with stock filters on
unpatched n9.0.2 and FFmpeg master ebfc9497f7 (#296, ADR-0191).
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk \
-hwaccel vulkan -hwaccel_device vk -hwaccel_output_format vulkan \
-i input.mkv \
-vf "pelorus_deband_vulkan=range=15:thry=0.012" \
-c:v hevc_vulkan -pix_fmt vulkan -qp 28 out.mkv # or av1_vulkan for AV1When the decoder, filter, and encoder all speak Vulkan/VRAM, no frame touches
system RAM. The deband graph above was not run for this page. These ran on
the RTX 4090 with hevc_vulkan -qp 28 and exit code 0 (120 frames): borderfix twice,
pelorus_mc_vulkan=meta=1 with pelorus_denoise_vulkan=mc=1 (with
-extra_hw_frames 4), and pelorus_grain_estimate_vulkan with
pelorus_denoise_vulkan.
tiling=drm on the last Pelorus filter that writes frames lets hwmap hand
its output to a VAAPI encoder, or on to QSV, without hwdownload (8-bit NV12
only; P010 and AMD wait on FFmpeg fixes, see
Vulkan output pools). Derive Vulkan from
the VAAPI device so both run on the same GPU, and decode with VAAPI so the
decoder output maps into Vulkan too. VAAPI encoder, verified on an Arc A380
(exit code 0, 120 frames):
LIBVA_DRIVER_NAME=iHD ffmpeg -v verbose -init_hw_device vaapi=va:/dev/dri/renderD130 \
-init_hw_device vulkan=vk@va \
-hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi -i input.mkv \
-vf "hwmap=derive_device=vulkan,pelorus_deband_vulkan=tiling=drm,hwmap=derive_device=vaapi,format=vaapi" \
-c:v h264_vaapi -rc_mode CQP -qp 20 out.mkvWith -v verbose the filter names the pool's layout, for example on an Arc
A380:
[Parsed_pelorus_deband_vulkan_1 @ 0x...] tiling=drm: nv12 1280x720 pool uses DRM format modifier 0x0100000000000009 (I915_FORMAT_MOD_4_TILED), chosen by the driver from 3
The log has no hwdownload and no scale filter, and the encoder reports
Using input frames context (format vaapi) with h264_vaapi encoder.
QSV encoder, mapped on from VAAPI; each hardware format is named so that format negotiation cannot pick another. Verified on the same card (exit code 0, 120 frames):
LIBVA_DRIVER_NAME=iHD ffmpeg -init_hw_device vaapi=va:/dev/dri/renderD130 \
-init_hw_device vulkan=vk@va \
-hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi -i input.mkv \
-vf "hwmap=derive_device=vulkan,pelorus_deband_vulkan=tiling=drm,hwmap=derive_device=vaapi,format=vaapi,hwmap=derive_device=qsv,format=qsv" \
-c:v hevc_qsv -q:v 24 out.mkvWith a software decoder, replace the decode options and the first hwmap by
-i input.mkv -vf "format=nv12,hwupload,..." (measured in ADR-0184, not
re-run for this page).
On an Arc A380 under xe, VAAPI encoders need constant QP (-rc_mode CQP -qp N) and QSV bitrate control needs -extbrc 1 (see below).
Two filters hold on to input frames after they have output them. Hardware decoders allocate a fixed pool of surfaces, so frames a filter keeps are surfaces the decoder cannot reuse.
| Filter | Frames kept | Default | Maximum |
|---|---|---|---|
pelorus_mc_vulkan |
the previous input frame, always | 1 | 1 |
pelorus_denoise_vulkan |
prev earlier frames plus the held frame when lookahead=1 |
3 | 5 |
The default is prev=3, lookahead=0; the maximum is prev=4 and
lookahead=1. scripts/check-doc-recipes.py reads the numbers from
PEL_DENOISE_MAX_PREV, the option defaults in
vf_pelorus_denoise_vulkan.c and the single AVFrame *prev of
vf_pelorus_mc_vulkan.c, and fails when this table or an -extra_hw_frames
value in a recipe disagrees. A chain that holds frames in both filters needs the
sum.
Pass -extra_hw_frames N before -i with N at least the number of frames the
chain keeps, so the decoder pool has room for them:
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk \
-hwaccel vulkan -hwaccel_device vk -hwaccel_output_format vulkan -extra_hw_frames 4 \
-i input.mkv \
-vf "pelorus_mc_vulkan=meta=1,pelorus_denoise_vulkan=prev=3:mc=1" \
-c:v hevc_vulkan -qp 28 out.mkvVerified (RTX 4090, exit code 0, 120 frames; mc 1 + prev=3 = 4). Starvation
without the option was not reproduced: the same chain behind VAAPI decode on
the Arc A380 and NVDEC with mc ran to completion without it, so the option is
a safeguard sized by the table, not a measured fix.
FFmpeg n9.0.2 has no Vulkan-to-AMF and no Vulkan-to-D3D11 path, and AMF takes
software, D3D11 or DXVA2 frames only (research 0172,
hop matrix rows D and G). A Windows or AMF recipe therefore cannot avoid a host
copy today, and none is given. Not run on hardware: the test host is Linux
without an AMF runtime.
libaom, SVT-AV1 and x265 read frames from system memory, so the Pelorus output must be downloaded. Keep Pelorus stages in VRAM and download once, last:
# hwdownload is inherent: libsvtav1 takes system-memory frames.
# Not run on hardware: the test binary has no libsvtav1.
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk -i input.mkv \
-vf "format=p010le,hwupload,pelorus_deband_vulkan,hwdownload,format=p010le" \
-c:v libsvtav1 -crf 35 -preset 6 out.mkv-vf "pelorus_analyze_vulkan,
pelorus_grain_estimate_vulkan=strength=2.0,
pelorus_mc_vulkan=bsize=16:search=24:meta=1,
pelorus_denoise_vulkan=sigma=0.03:mc=1,
pelorus_deband_vulkan=range=15"pelorus_grain_estimate_vulkan reads the source grain, so it runs before
denoise removes it. AV1 consumers can re-synthesize grain from the emitted native
frame params; the HEVC pelorus_fgs BSF instead requires a static model supplied
manually through its AVOptions and does not read those frame params inline.
pelorus_mc_vulkan is a pass-through producer: with meta=1 it emits per-block
motion and confidence fields. pelorus_denoise_vulkan=mc=1 consumes them for a
confidence-gated temporal warp, while the optional NVENC
NV_ENC_EXTERNAL_ME_HINT consumer uses the motion field as an encode-search
hint. AVOptions: bsize (block edge, default 16), search (radius, default 24),
meta.
Each stage runs in VRAM; the Pelorus side-data blob accumulates sections and rides every frame to the encoder.
Score the processed-then-encoded output against the source, in the same graph:
# hwdownload is inherent here: libvmaf_tune scores system-memory frames.
# Not run on hardware: the test binary has no libvmaf_tune.
ffmpeg -i src.mkv -i src.mkv -filter_complex \
"[0:v]hwupload,pelorus_deband_vulkan=thry=0.012:meta=1,hwdownload,format=p010le[pre];
[pre][1:v]libvmaf_tune=recommend_target_vmaf=93:model=version=vmaf_v0.6.1" \
-f null -libvmaf_tune (from the vmafx FFmpeg patches) logs a recommended_crf= line and
can read the Pelorus banding/variance sections for perceptual weighting. For a
distributed sweep, score finished encodes via vmafx-server POST /v1/score or
the vmaf-mcp vmaf_score_encoded tool. See
ADR-0106.
vf_pelorus_analyze roi=1 emits AV_FRAME_DATA_REGIONS_OF_INTEREST (a per-cell
banding/quality qoffset map). Vanilla NVENC ignores ROI side data and vanilla
QSV honors only coarse rectangle regions; the Pelorus patch stack adds a
-pelorus_roi 1 AVOption to both. NVENC consumes the same side data into
qpDeltaMap; progressive HEVC QSV under CQP on runtime API 1.28 or newer can
consume it through a dense mfxExtMBQP delta map:
# HEVC, NVENC, constant-QP (the clean mode for QP-map steering). Verified on
# an RTX 4090, exit code 0, 120 frames. Behind NVDEC derive Vulkan from CUDA
# (#296, ADR-0191).
ffmpeg -init_hw_device cuda=cu:0 -init_hw_device vulkan=vk@cu -filter_hw_device vk \
-hwaccel cuda -hwaccel_device cu -hwaccel_output_format cuda -i input.mkv \
-vf "hwupload,pelorus_analyze_vulkan=roi=1,hwupload=derive_device=cuda" \
-c:v hevc_nvenc -rc constqp -qp 30 -pelorus_roi 1 out.mkv
# HEVC, Intel QSV, progressive CQP (-q:v also sets AV_CODEC_FLAG_QSCALE).
# Verified on an Arc A380, exit code 0, 120 frames; the log says "Pelorus ROI:
# requesting per-frame HEVC delta-QP maps (mfxExtMBQP)". 8-bit NV12 only.
LIBVA_DRIVER_NAME=iHD ffmpeg -init_hw_device vaapi=va:/dev/dri/renderD130 -init_hw_device vulkan=vk@va \
-hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi -i input.mkv \
-vf "hwmap=derive_device=vulkan,pelorus_analyze_vulkan=roi=1,pelorus_deband_vulkan=tiling=drm,hwmap=derive_device=vaapi,format=vaapi,hwmap=derive_device=qsv,format=qsv" \
-c:v hevc_qsv -q:v 30 -pelorus_roi 1 out.mkvThe option is registered on hevc_qsv, h264_qsv, hevc_nvenc, h264_nvenc,
av1_nvenc and libsvtav1 (swap the encoder above accordingly; the SVT-AV1
mapping differs — see below). It defaults OFF (zero
behaviour change). Use constant-QP and the encoder's own spatial/temporal AQ
OFF: the encoder AQ overrides the delta-QP map, and VBR rate-control
redistribution erodes the perceptual win.
NVENC adds each qpDeltaMap entry to the rate-control QP in the codec's own QP
units, so a region's delta is qoffset scaled by a per-codec span:
51 + 6 × (bit_depth − 8) H.264/HEVC QP steps for h264_nvenc/hevc_nvenc,
and 255 AV1 qindex steps for av1_nvenc (the same scale as its
-qp/-qmin/-qmax, and the scale the libaom, SVT-AV1, VAAPI and D3D12 AV1 ROI
paths use). The map holds signed bytes, so an AV1 delta saturates at
[−128, 127] qindex; the analyze filter's default roi_strength=0.333 stays inside
that range.
For QSV, use -q:v N (or otherwise set AV_CODEC_FLAG_QSCALE) to select CQP.
-global_quality N alone selects ICQ in FFmpeg n9.0.2 and therefore cannot use
the dense MBQP path. The patch does not add -pelorus_roi to av1_qsv.
QSV selects the dense path only for progressive HEVC+CQP when the runtime API is
1.28 or newer and the build headers expose mfxExtMBQP (oneVPL/MediaSDK API
1.13 or newer). H.264, older runtimes, non-CQP HEVC, interlaced HEVC, and builds
without that header surface retain FFmpeg's stock per-region
mfxExtEncoderROI steering; the option reports the fallback instead of
disabling ROI. On a dense-path frame the map and header are owned by that frame
until its asynchronous QSV surface unlocks. The map grid uses oneVPL's aligned
storage dimensions while ROI coordinates are clipped to the visible frame,
leaving storage-padding cells at zero.
EnableMBQP=ON is an initialization request, not a runtime capability probe.
Dense selection caches the final attached CodingOption3 value after init/reset
and requires that field to remain ON after any AVQSVContext replacement. The
patch never attaches mfxExtMBQP and mfxExtEncoderROI to the same frame.
See QSV ROI steering,
ADR-0114, and its QSV contract correction
ADR-0146.
The xe kernel driver loads no HuC firmware on DG2 (Arc A-series), and the
media driver's low-power encoder, the only one DG2 has, runs its bitrate
control on HuC. On such a host every hardware bitrate-control mode (CBR, VBR,
ICQ) fails without writing a frame: h264_qsv logs GPU Hang (-21) or
Invalid FrameType:0, hevc_qsv and av1_qsv Invalid FrameType:0. VA-API
(-rc_mode CBR) fails the same way with Input/output error. Constant QP
(-q:v N) works.
For CBR or VBR on h264_qsv and hevc_qsv, add -extbrc 1. oneVPL then
picks each frame's QP on the CPU and drives the driver in constant QP, which
needs no HuC; the encoder log says ExtBRC: ON. On an A380 this held CBR within
2 % of the target over 16 seconds:
LIBVA_DRIVER_NAME=iHD ffmpeg -init_hw_device vaapi=va:/dev/dri/renderD130 -init_hw_device vulkan=vk@va \
-hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi -i input.mkv \
-vf "hwmap=derive_device=vulkan,pelorus_deband_vulkan=tiling=drm,hwmap=derive_device=vaapi,format=vaapi,hwmap=derive_device=qsv,format=qsv" \
-c:v hevc_qsv -b:v 4M -maxrate 4M -bufsize 4M -extbrc 1 out.mkv- ICQ (
-global_quality Nwithout a bitrate) has no software counterpart and stays unavailable; so doesav1_qsvbitrate control, because oneVPL applies-extbrcto AV1 only through look-ahead tools that also failed on the A380. Use-q:v Nfor AV1. - With
-extbrc 1the session is not constant QP, so-pelorus_roi 1onhevc_qsvtakes the stock ROI rectangles, not the dense map (QSV ROI steering). - VA-API encoders (
hevc_vaapi,h264_vaapi) have no software bitrate control: constant QP only on this setup. ROI side data throughhevc_vaapiproduces a corrupt stream on the A380 even in constant QP; steer throughhevc_qsv.
To name the GPU instead of letting FFmpeg pick the Intel render node, either form works:
-init_hw_device vaapi=va:/dev/dri/renderD130 -init_hw_device qsv=qs@va -filter_hw_device qs
-init_hw_device qsv=qs:hw,child_device=/dev/dri/renderD130 -filter_hw_device qsfollowed by -vf "...,hwupload=extra_hw_frames=64" for QSV frames. If the
environment sets LIBVA_DRIVER_NAME for another GPU (for example nvidia on a
host whose desktop runs on an NVIDIA card), set it to iHD for the Intel
command: otherwise libva loads the other driver and QSV fails with Error creating a MFX session: -9, whichever form is used. Measurements:
research 0180; the tester's
bitrate-control legs check this on every Intel run
(tester kit stages).
The same -pelorus_roi 1 AVOption is registered on libsvtav1 (the av1_svt
flagship modern AV1 software encoder). Vanilla libsvtav1 consumes no ROI
side data at all — this is the primary gap that patch fills. It consumes the
same AV_FRAME_DATA_REGIONS_OF_INTEREST side data, but maps it onto
SVT-AV1's native per-superblock ROI segment map (SvtAv1RoiMapEvt) rather
than a per-block delta-QP map, because that is the ABI SVT-AV1 exposes:
# AV1, SVT-AV1, constant-quality CRF (the clean mode for ROI segment steering).
# hwdownload is inherent: libsvtav1 takes system-memory frames.
# Not run on hardware: the test binary has no libsvtav1.
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk -i in.mkv \
-vf "format=p010le,hwupload,pelorus_analyze_vulkan=roi=1,hwdownload,format=p010le" \
-c:v libsvtav1 -crf 35 -preset 6 -pelorus_roi 1 out.mkvEach frame's ROI rectangles are rasterized onto the 64×64-superblock grid and
quantised into up to 8 AV1 segments (MAX_SEGMENTS); each segment carries a
seg_qp qindex delta added to the frame base qindex (negative = lower qindex =
more bits, matching the qoffset sign). Segment 0 is the zero-delta background,
so superblocks no region covers keep the encoder's default decision. The map is
attached per frame via SVT-AV1's ROI_MAP_EVENT private-data node and
enable_roi_map is turned on at init. SVT-AV1 never copies or frees the event
(it keeps the bare pointer, and a frame without an ROI node inherits the last
one), so the encoder owns each event and frees it once a newer event has
replaced it and the frames before that newer event are encoded (judged from the
packet count with a 128-frame margin). Memory stays flat over the stream length
instead of growing per frame; -v verbose prints the built and peak-live event
counts at close.
Because the library's last event is sticky, a frame that carries no ROI side data (or an ROI whose deltas all round to zero) would silently keep the previous frame's ROI. SVT-AV1 documents no reset call, so on the first such frame after a non-neutral event the encoder submits a neutral event (every superblock in segment 0, zero delta); the following frames inherit that neutral map. Measured with libsvtav1 4.2.0, CRF 30, 90 frames, ROI on the first 33 frames only: before the fix the remaining frames were byte-identical to an encode with the ROI on all frames (1130764 bytes); with the fix they match an encode without any ROI (2034761 vs 2036142 bytes).
Use constant-quality (-crf / -qp); SVT-AV1's own variance AQ can override
the segment map, and VBR rate-control redistribution erodes the win (same caveat
as NVENC/QSV). The whole path is compile-gated by SVT-AV1 ≥ 1.6.0 (the release
that introduced the ROI-map ABI); built against an older SVT-AV1 the option warns
once at init and no-ops. Defaults OFF (zero behaviour change). See
ADR-0121.
The same -pelorus_roi 1 AVOption is registered on the native Vulkan-Video
encoders h264_vulkan, hevc_vulkan and av1_vulkan (one shared edit in
vulkan_encode.c, so all three gain it at once). It consumes the same
AV_FRAME_DATA_REGIONS_OF_INTEREST side data through
VK_KHR_video_encode_quantization_map — one producer steers bit allocation on
every GPU vendor's Vulkan encoder, with no host roundtrip:
# HEVC, native Vulkan-Video encoder, constant-QP (GPU-resident after upload).
# Not run on hardware for this page: the verified Vulkan-encoder runs used Vulkan decode and no ROI option.
ffmpeg -init_hw_device vulkan=vk:0 -filter_hw_device vk -i input.mkv \
-vf "format=p010le,hwupload,pelorus_analyze_vulkan=roi=1" \
-c:v hevc_vulkan -rc_mode cqp -qp 30 -pelorus_roi 1 out.mkvThe map kind is chosen automatically from the negotiated rate-control mode: a
signed delta-QP map under CQP, or an emphasis map under CBR/VBR. Each
is written in the texel format the driver advertises: R8_SINT, R16_SINT
or R32_SINT for the delta map (NVIDIA advertises R8_SINT, RADV
R32_SINT), R8_UNORM or R16_UNORM for the emphasis map. Use
-rc_mode cqp for the delta map; no driver tested so far advertises the
emphasis map.
The path is fully runtime-probed and any miss degrades to a one-shot warning plus pass-through:
- Device enablement. The device must enable both the
VK_KHR_video_encode_quantization_mapextension and itsvideoEncodeQuantizationMapfeature. Patch 0009 adds both to FFmpeg's optional Vulkan device extensions and features, so a device created by FFmpeg (-init_hw_device vulkan=…, or one derived from another hardware device) enables them automatically whenever the driver supports them; check the-v verboselineUsing device extension VK_KHR_video_encode_quantization_map. An application that passes its ownAVVulkanDeviceContextmust enable both itself. Passing the extension alone through thedevice_extensionsoption is not enough: without the feature the session is invalid (VUID-VkVideoSessionCreateInfoKHR-flags-10264). - Map format and fill path. The codec must advertise the delta or emphasis
capability flag, and
vkGetPhysicalDeviceVideoFormatPropertiesKHRmust return an entry in one of the formats above that some fill path can write (see below). Drivers answer that query with the image usages asked for, so the probe asks for the map usage together with the fill usages the encode queue family can record, then for the map usage alone. The map image is created with the chosen entry's tiling. With no fillable entry the probe warnspelorus_roi: none of the N advertised delta-map format entries is fillable on this encode queue family (...); disabled.and-v verboselists every entry with the fill path it allows. - Delta range. Each delta-map value is clamped to the driver's per-codec
range (
minQpDelta/maxQpDeltafor H.264/H.265,minQIndexDelta/maxQIndexDeltafor AV1) as well as to theqoffsetspan; a value outside the driver range would leave the block QP undefined. When the range excludes negative values the probe warns that regions asking for a lower QP get no extra bits. The value is also clamped to the range of the map's texel format. - Map size. A picture whose map (width and height over the texel block,
rounded up) exceeds the driver's
maxQuantizationMapExtentdisables steering with a warning; the map is never clamped. RADV allows 256x256 texels for H.264 (16x16 px) and 128x68 for H.265 (64x64 px, 8192x4352).
The qoffset span is per codec: 51 + 6 × (bit_depth − 8) QP steps for H.264
and H.265, and 255 qindex steps for AV1 (the NVENC, libaom and SVT-AV1 scale; the
H.26x span would give an AV1 region a fifth of the requested delta). -v debug
prints each applied rectangle (pelorus_roi: texels (0,0)-(10,12) qoffset 0.200 -> delta 51 (span 255, clamp [0, 127])).
-v verbose prints the chosen map (Pelorus QP-map steering enabled: delta map, format 99 (4 bytes/texel), 40x23 texels (16x16 px/texel, linear tiling), dQP [-51, 51], fill: host raster into a mapped linear image.; format 99 is
R32_SINT, 14 is R8_SINT) and -v debug prints one
pelorus_roi: frame N: host-mapped map slot S, R region(s) line per frame that
binds a map (host for the staging copy, on-GPU for the compute raster). A
frame with no ROI side data binds no map (zero behaviour change). With a map
bound, hevc_vulkan signals per-CU QP deltas (cu_qp_delta_enabled_flag) even
under CQP.
Every fill is recorded on the encode command buffer, so the encode queue family's own capabilities decide how the map can be written (ADR-0182). The probe takes the first path in this order that an advertised entry allows:
- On-GPU raster. The canonical build-time
libavcodec/vulkan/pelorus_qpmap.comp.glslcompute shader reads the coalesced ROI rectangle list from a small SSBO andimageStores the per-texel delta/emphasis. NeedsVK_QUEUE_COMPUTE_BITon the encode queue family,STORAGEusage, and an 8-bit map format (R8_SINT/R8_UNORM, the formats the shader declares). No tested driver has compute on its encode queue family. - Staging copy. The host rasterizes into a staging buffer and
vkCmdCopyBufferToImageuploads it. Needs a transfer-, graphics- or compute-capable encode queue family andTRANSFER_DSTusage. NVIDIA uses this path. - Host-mapped image. The host rasterizes straight into a persistently
mapped
LINEARmap image in host-visible memory; the command buffer only moves the image intoVIDEO_ENCODE_QUANTIZATION_MAP_KHRlayout before the encode and back toGENERALafter it. Needs aLINEARentry and nothing from the queue family. RADV, whose encode queue family hasVK_QUEUE_VIDEO_ENCODE_BIT_KHRonly, uses this path.
There is one map image per encode execution context, created with its memory
when the encoder opens, before the video session; a missing memory type or a
failed image disables steering there with a warning, and nothing is allocated
per frame. A context's fence wait guards the image before the host or the GPU
writes it again. All paths share
the same qoffset→ΔQP convention, are recorded before the video coding scope
begins, and default off.
Driver status (measured 2026-10-03, ADR-0166; RADV row 2026-10-09, ADR-0182).
Device / driver Extension + feature Delta map advertised Result RTX 4090, NVIDIA 615.71.09 yes R8_SINT, LINEAR only, ΔQP [0, 51] (H.264/H.265), ΔqIndex [0, 255] (AV1)Steering active on all three encoders; only positive offsets (raise QP) take effect Radeon 610M iGPU, RADV (Mesa 26.2.4) yes R32_SINT, OPTIMAL, LINEAR and DRM-modifier tiling, usages as queried, ΔQP [-51, 51] (H.264/H.265); encode queue familyVIDEO_ENCODEonlySteering active on h264_vulkanandhevc_vulkanthrough the host-mappedLINEARmap; both signs ofqoffsettake effectArc A380, ANV (Mesa 26.2.4) no — Disabled with a warning (no extension; video encode itself needs ANV_DEBUG=video-encode)On NVIDIA a
+0.3qoffsetover the left half of a 1080p clip at-qp 30raised the decoded H.264 macroblock QP there from 30 to about 45, cut the stream by 30 % (HEVC: 50 %) and left the other half's PSNR unchanged. The negative offsetspelorus_analyze_vulkan roi=1emits ("spend more bits here") clamp to 0 on this driver, so they bind a neutral map.av1_vulkanon this driver already emits streams libdav1d cannot fully decode without-pelorus_roi, so its quality was not measured.On RADV a
-0.3qoffsetover the left half of a noisy 640x360 clip at-qp 30raised that half's PSNR from 36.7 to 44.8 dB (H.264; HEVC 36.8 to 45.4 dB) and+0.3lowered it to 31.7 dB (HEVC 32.4 dB), with the right half within 0.2 dB. Mesa 25.0 RADV has no quantization-map extension and passes through with the "device does not enable" warning. See ADR-0114 Tier 2, ADR-0166 and ADR-0182.
vf_pelorus_mc_vulkan emits a per-block integer-pel motion-vector field as the
PEL_SEC_MOTION interop section (see metrics/mc.md). Vanilla
NVENC always runs the ASIC's own motion search and cannot be seeded with
externally-computed vectors; the Pelorus patch stack adds a -pelorus_me_hints 1
AVOption (NVENC, H.264/HEVC only) that reads that MV field and feeds it to
NVENC's external-ME-hint input (enableExternalMEHints +
NV_ENC_PIC_PARAMS::meExternalHints):
# HEVC, NVENC: produce the MV field, then let NVENC seed its search from it.
# Verified on an RTX 4090, exit code 0, 120 frames. Behind NVDEC derive Vulkan
# from CUDA (#296, ADR-0191).
ffmpeg -init_hw_device cuda=cu:0 -init_hw_device vulkan=vk@cu -filter_hw_device vk \
-hwaccel cuda -hwaccel_device cu -hwaccel_output_format cuda -extra_hw_frames 1 -i in.mkv \
-vf "hwupload,pelorus_mc_vulkan=bsize=16:search=24,hwupload=derive_device=cuda" \
-c:v hevc_nvenc -preset p5 -cq 28 -pelorus_me_hints 1 out.mkvThe value is encode SPEED, not quality. The MV field is a hint the fixed-function encoder can use to skip or shorten its own motion search; it does not change the rate-control target. One L0 candidate is supplied per 16×16 block (the SDK-documented external-hint granularity for AVC/HEVC); the producer's block grid is resampled onto that 16×16 grid by center-block sampling, and each vector is clamped to NVENC's hint bitfield range (mvx S12, mvy S10).
The option defaults OFF (zero behaviour change). Capability degradation is
graceful: AV1 is skipped (NVENC's AV1 path uses a different per-superblock hint
struct), a device that reports no external-ME support warns once and passes
through, and an FFmpeg built against ffnvcodec headers without the external-ME
structs (pre-SDK-8.1) no-ops at init with a one-shot warning. If a frame carries
no PEL_SEC_MOTION section (no pelorus_mc_vulkan in the chain, or a frame the
filter produced no vectors for), the patch submits one zero-MV candidate per
16×16 block for that frame and logs one warning per encoder instance. A session
opened for external hints requires a populated hint buffer on every frame:
submitting zero candidates fails the whole encode on the device (EncodePicture failed!: invalid param (8): SetupCEAHints failed, RTX 4090, driver 615.71.09),
and the SDK documents no other "no hint" value. A zero-MV seed is neutral but not
identical to hints-off: the ASIC still searches around the co-located block, so
the stream differs from an encode without -pelorus_me_hints. On-hardware A/B (RTX 4090, hevc_nvenc -preset p7,
1280×720, 600 frames): hints engaged but produced a ~2–3% slowdown (hints-off
114 fps vs hints-on 110 fps) — the per-frame hint upload outweighs ME-search
savings on Ada VDEnc at p7. Kept default off and documented as an honest negative
(see bench-results.md v0.9). It may still help on slower ME engines or
higher-motion content.
See ADR-0114 Tier 3 and
ADR-0116.
vf_pelorus_grain_estimate_vulkan measures film grain and emits AV1 (AOM)
film-grain-synthesis parameters as a native AV_FRAME_DATA_FILM_GRAIN_PARAMS
(AV_FILM_GRAIN_PARAMS_AV1) and the PEL_SEC_FILMGRAIN interop section (see
metrics/grain_estimate.md). AV1 software
encoders already consume that native side data; HEVC is covered by the
pelorus_fgs BSF (see grain-fgs-bsf.md). Stock av1_nvenc
does neither — it never enables NVENC's hardware AV1 film-grain synthesis, so
the estimate is dropped and the grain is coded as residual. The Pelorus patch
stack adds a -pelorus_film_grain 1 AVOption (av1_nvenc only) that carries
the estimate into NVENC's AV1 film-grain config so the decoder re-synthesizes
the grain:
# AV1, NVENC: estimate the grain, then let NVENC re-synthesize it in hardware.
# Verified on an RTX 4090, exit code 0, 120 frames (8-bit input). Behind NVDEC
# derive Vulkan from CUDA (#296, ADR-0191).
ffmpeg -init_hw_device cuda=cu:0 -init_hw_device vulkan=vk@cu -filter_hw_device vk \
-hwaccel cuda -hwaccel_device cu -hwaccel_output_format cuda -i in.mkv \
-vf "hwupload,pelorus_grain_estimate_vulkan,hwupload=derive_device=cuda" \
-c:v av1_nvenc -cq 32 -pelorus_film_grain 1 out.mkvThe win is the same as all FGS: the encoder spends a few bytes of grain model
instead of megabits of structureless residual, and the viewer still sees grain.
Pair it with pelorus_denoise_vulkan upstream (remove the grain before encode,
re-synthesize it at decode) for the full noise-tax recovery.
How it works: at init the patch sets NV_ENC_CONFIG_AV1::enableFilmGrainParams
and points filmGrainParams at a persistent NV_ENC_FILM_GRAIN_PARAMS_AV1; per
frame it refills that struct from the estimate (native channel preferred, the
interop section as a fallback) and raises the AV1 pic-params
filmGrainParamsUpdate flag (a time-varying model). The AVFilmGrainAOMParams
set maps field-for-field onto the NVENC struct. NVENC takes the raw AV1 syntax
values, so the chroma multipliers gain the AV1 +128 bias and the chroma offsets
the +256 bias on the way in (AVFilmGrainAOMParams and the
PEL_SEC_FILMGRAIN section carry them unbiased, as dav1d exports them).
The option defaults OFF (zero behaviour change) and is registered on av1_nvenc
only — H.264/HEVC NVENC have no AV1 film grain. It is compile-gated by a new
NVENC_HAVE_AV1_FILM_GRAIN macro (ffnvcodec SDK 12.0+, where the AV1 encoder and
the film-grain struct landed); an FFmpeg built against older headers warns once
at init and passes through. If a frame carries no usable AV1 grain estimate the
update flag stays clear, so NVENC keeps the previous params (or the zero-init
no-op). On an RTX 4090 (driver 615.71.09) the encoded stream signals
film_grain_params_present=1 with apply_grain=1 in every frame header, and
dav1d synthesizes the grain at decode. A libaom film-grain test vector decoded
through libdav1d keeps every film-grain syntax value except grain_seed, which
NVENC chooses itself. No grain-match or BD-rate number ships yet. See
ADR-0118,
ADR-0115, and
ADR-0114.
- Build FFmpeg with the Pelorus patch stack applied and
libpelorusinstalled (see ffmpeg-patches/README.md). - Prefer 10-bit intermediate/output even for 8-bit delivery — it preserves the dithered gradient through quantization (research 0101).
- List options:
ffmpeg -h filter=pelorus_deband_vulkan.
The PelorusSideData blob rides each frame as AV_FRAME_DATA_SEI_UNREGISTERED.
Encoders write it into the H.264 or HEVC stream as a user-data-unregistered SEI
message when -udu_sei 1 is set (default off):
| Encoder | Source of the option | Form in the stream | Size limit per picture |
|---|---|---|---|
h264_nvenc |
stock FFmpeg; truncation drop in shared series patch 0004, carrier in patch 0022 | zero-free carrier | none found; carriers tested to 60 017 bytes (RTX 4090) |
hevc_nvenc |
stock FFmpeg; limit in shared series patch 0003, truncation drop in 0004, carrier and map stripping in patch 0022 | zero-free carrier | 768 bytes of SEI (see below) |
h264_qsv |
patch 0019, budget in patch 0023 | blob | 40 960 bytes of SEI per picture (see below) |
hevc_qsv |
patch 0019, budget in patch 0023 | blob | 4 040 bytes of SEI per picture (see below) |
h264_vulkan, hevc_vulkan |
patch 0020 | blob | none measured |
On an RTX 4090 with driver 615.78.08, NVENC writes a SEI truncated when the payload needs more
emulation prevention bytes than about a third of its length (ceil(P / 3) + 3,
pinned on that driver), and the decoder then drops it without an error
(issue #284). The analyze maps of flat content are mostly zero bytes
and reach that. Both NVENC encoders therefore write every Pelorus blob in its
zero-free carrier form: UUID 3f9b37b8-fd9a-4621-920e-9b78b55cf9b5, then the
COBS-encoded blob, with no zero byte, one byte longer per 254 bytes
(ADR-0183,
interop ABI 1.5). A reader
of the decoded frames turns it back into the blob with pel_blob_unwrap();
-loglevel verbose names the form once. A payload from another producer is
written as it is, unless NVENC would truncate it: patch 0004 of the shared
FFmpeg fix series (ADR-0185) then
leaves it out and warns (Not writing a N-byte user data unregistered SEI at pts ...: NVENC would write it truncated): the first time as a warning, later
at -loglevel verbose, with the total when the encoder closes. Before, the
truncated SEI reached the stream and the decoder discarded it. A carrier holds
no zero byte and is never affected:
[h264_nvenc] udu_sei: Pelorus side data is written in its zero-free carrier form (UUID 3f9b37b8-fd9a-4621-920e-9b78b55cf9b5), which needs no emulation prevention.
A reader built before ABI 1.5 (VMAFx before its re-pin) ignores the carrier and sees no Pelorus data on frames from an NVENC stream.
hevc_nvenc fails a picture whose parameter sets and SEI exceed 1024 bytes
(ADR-0181,
research 0181). Patch 0003
of the shared series keeps 768 of them for the side data, in frame order, and
patch 0022 strips a Pelorus blob to fit:
- an entry that fits is written whole; a Pelorus blob is charged as its carrier, which needs no emulation prevention;
- a Pelorus blob that does not fit is written without its per-cell maps. Every
scalar section stays, and the carrier unwraps to what
pelorus_analyze_vulkan=maps=0writes. The analyze maps fit only up to about 90 cells (flat content: 10x6 cells at 320x180), so at real frame sizes the stream carries the scalars and the maps stay in the filter graph; - any other entry that does not fit is not written.
The encoder warns at the first stripped blob and the first dropped entry, logs
later ones at -loglevel verbose, and prints both totals when it closes:
[hevc_nvenc] udu_sei: frame pts 0: the 12424-byte Pelorus side data is written without its per-cell maps (184 bytes): hevc_nvenc writes at most 1024 bytes of parameter sets and SEI per picture.
[hevc_nvenc] Not writing a 2017-byte user data unregistered SEI at pts 0: hevc_nvenc fails a picture whose parameter sets and SEI exceed 1024 bytes.
Without these patches the encode stopped with Failed locking bitstream buffer: out of memory (-12). ffmpeg-patches/test/nvenc-udu-sei-smoke.sh checks the
cases, the flat-content carriers of issue #284 included, on an NVIDIA host and
exits 77 with the reason elsewhere.
# Verified on an Arc A380, exit code 0, 120 frames.
LIBVA_DRIVER_NAME=iHD ffmpeg -init_hw_device vaapi=va:/dev/dri/renderD130 -init_hw_device vulkan=vk@va \
-hwaccel vaapi -hwaccel_device va -hwaccel_output_format vaapi -i input.mkv \
-vf "hwmap=derive_device=vulkan,pelorus_analyze_vulkan,pelorus_deband_vulkan=tiling=drm,hwmap=derive_device=vaapi,format=vaapi,hwmap=derive_device=qsv,format=qsv" \
-c:v hevc_qsv -udu_sei 1 -q:v 24 out.mkvThe QSV runtime has a fixed amount of room for SEI in each picture, and larger payloads damage the
encoded access unit: on hevc_qsv no picture of the stream decodes any more, on h264_qsv the encode
fails (#286, research 0286).
The room is per picture, not per payload: it is the sum of the SEI messages as handed to the runtime
(type byte, size bytes and payload), and on h264_qsv it also counts the emulation prevention bytes.
Measured on an Intel Arc A380 (iHD 26.3.5, vpl-gpu-rt 26.3.5, libvpl 2.17.0) at 640x360, 1080p and
2160p alike, the limits are 4 107 bytes for hevc_qsv and about 42 420 bytes for h264_qsv. Patch 0023
keeps 4 040 and 40 960 bytes, less what the A/53 caption payload already uses, and treats each
SEI_UNREGISTERED entry in frame order like hevc_nvenc does (ADR-0181):
- an entry that fits is written whole;
- a Pelorus blob that does not fit is written without its per-cell maps (every scalar section stays, the
maps are absent as the ABI defines), which is what
pelorus_analyze_vulkan=maps=0writes; - any other entry that does not fit, and a blob whose scalars do not fit, is not written.
QSV writes the plain blob; the zero-free carrier is for NVENC. At 1080p the cell=32 blob (12 424
bytes) therefore reaches an hevc_qsv stream as its 184 scalar bytes and an h264_qsv stream whole;
the cell=16 blob (49 144 bytes) loses its maps on both. Only two payloads per picture are queued
(QSV_MAX_ENC_PAYLOAD); the warning of patch 0019 names a third. The encoder warns at the first
stripped blob and the first dropped entry, logs later ones at -loglevel verbose, and prints both totals
when it closes:
[hevc_qsv] udu_sei: frame pts 0: the 12424-byte Pelorus side data is written without its per-cell maps (184 bytes): the QSV runtime reserves too little SEI space per picture for more.
On the A380, 48 analyze blobs (184 to 49 144 bytes) through both encoders gave no damaged access unit.
Another runtime or GPU needs a new measurement; the budgets are the constants of
pelorus_sei_fit_qsv.h.
Known issue: h264_qsv with -udu_sei 1 can crash intermittently, more often with larger payloads
(2 KB: none in 80 runs; 16 KB: about 12 to 15 in 80; 30 KB: 11 to 30 in 80; none in 60 runs without
payloads, none in 80 on hevc_qsv). The crash happens inside Intel's QSV runtime and reproduces with stock
FFmpeg (its A/53 captions too), so Pelorus does not cause it, and the budget does not prevent it. A fix
is being worked on outside Pelorus (research 0286).
AV1 encoders do not carry it. The tester stage sidedata_roundtrip reads the
blob back from the stream and from a decode: tester kit stages.