VmafPicture v2 — consumer migration guide¶
Status: Implemented (cycle N+1). v2 is declared in
core/include/libvmaf/picture_v2.hand linked intolibvmaf.soviacore/src/picture_v2.c. See ADR-0928 for the decision record and the four-cycle migration plan.
Why v2 exists¶
The v1 struct (core/include/libvmaf/picture.h) carries a single void *priv that every backend (CPU, CUDA, SYCL, HIP, Metal) overlays its own shadow record on top of. There is no way for a downstream consumer to inspect a VmafPicture and discover which backend owns its plane data — misrouting a CUDA-backed picture into a SYCL extractor is silent UB rather than an explicit -EINVAL.
v2 replaces the void *priv overlay pattern with two explicit fields:
typedef struct VmafPicture2 {
/* ... v1 prefix (pix_fmt, bpc, w, h, stride, data, ref) ... */
void *priv;
VmafBackendHandle backend; /* discriminator */
uintptr_t backend_handle; /* CUstream / hipStream_t / queue cookie / ... */
uintptr_t _reserved[4]; /* additive-growth tail */
} VmafPicture2;
backend is an enum (VMAF_BACKEND_HANDLE_NONE | _CUDA | _SYCL | _HIP | _METAL | _VULKAN). backend_handle carries the typed stream/queue pointer for that backend, narrowed at the consumer side via a documented cast (CUstream s = (CUstream)pic.backend_handle;).
Timeline (4 cycles)¶
| Cycle | Window | What ships | SONAME | v1 status |
|---|---|---|---|---|
| N | 2026-05-31 | picture_v2.h declared; ADR-0928; this doc; changelog fragment | 3 | live default |
| N+1 (this RC) | 2026-06-13 | picture_v2.c implemented; header installed; unit tests; picture_v2.h wired into meson | 3 (additive) | live default |
| N+2 | ≈ 6 months | In-tree backends + tools + ffmpeg-patches/0002-0006 switched to v2 | 3 | callable but unused in-tree |
| N+3 | ≈ 12 months (target VMAFX v4.0.0) | v1 entry points removed; typedef VmafPicture2 VmafPicture; | 3 → 4 | removed |
Migration recipes¶
FFmpeg filter (ffmpeg-patches/0002-0006)¶
Before (v1):
VmafPicture pic;
vmaf_picture_alloc(&pic, VMAF_PIX_FMT_YUV420P, 8, 1920, 1080);
copy_picture_data(frame, &pic, 8);
vmaf_read_pictures(ctx, &pic, NULL, frame_index);
vmaf_picture_unref(&pic);
After (v2):
VmafPicture2 pic;
vmaf_picture2_alloc(&pic, VMAF_PIX_FMT_YUV420P, 8, 1920, 1080);
/* pic.backend == VMAF_BACKEND_HANDLE_NONE — CPU picture */
copy_picture_data_v2(frame, &pic, 8);
vmaf_read_pictures2(ctx, &pic, NULL, frame_index);
vmaf_picture2_unref(&pic);
The hwaccel-import filters (libvmaf_sycl, libvmaf_vulkan, libvmaf_metal) set pic.backend and pic.backend_handle directly instead of stashing the stream inside pic.priv.
Coordination rule: Per CLAUDE.md §12 r14, the cycle-N+1 PR that implements vmaf_picture2_alloc must update the FFmpeg patch stack in the same PR so the next git am --3way against n8.1 does not break. For the cycle-N+2 switch, each of patches 0002–0006 ships an updated copy that consumes v2 directly.
Rust safe binding (modernization #11)¶
The Rust wrapper targets v2 from day one — it never wraps v1. The binding maps VmafBackendHandle to a Rust enum and exposes the stream handle as a typed associated type:
pub enum Backend {
None,
Cuda(CudaStream),
Sycl(SyclQueue),
Hip(HipStream),
Metal(MetalCommandQueue),
}
pub struct Picture {
inner: ffi::VmafPicture2,
}
impl Picture {
pub fn backend(&self) -> Backend { /* match on self.inner.backend */ }
}
Until cycle N+1 ships the implementation, the Rust binding can build against the header but every call returns -ENOSYS (the design header declares the entry points but they are not yet linked).
MCP server (mcp-server/vmaf-mcp/)¶
The MCP tool surface continues to accept v1 pictures in cycle N+1. In cycle N+2 the JSON-RPC schema gains an optional backend field on picture descriptors; absent → none (CPU). The MCP server uses vmaf_picture_v1_to_v2 internally to promote inbound v1 pictures.
Controller / node (ADR-0709, Phase 4b)¶
The controller-to-node gRPC protocol assumes v2's typed backend handle from inception — vmafx-node workers report backend: VMAF_BACKEND_HANDLE_CUDA (etc.) explicitly so the controller can schedule cross-node parity sweeps onto matched silicon.
Downstream Python wheels¶
The Python harness (compat/python-vmaf/) wraps v1 today. Cycle N+1 adds a v2 wrapper; cycle N+2 deprecates the v1 wrapper. Pre-built wheels distributed via PyPI follow the SONAME bump in cycle N+3.
What does NOT change¶
- Plane data layout (
data[3],stride[3]) is identical between v1 and v2. Hot loops that indexpic.data[plane][y * stride + x]read the same bytes either way. VmafRefrefcounting behaves the same. v2'srefslot is the sameVmafRef *as v1.- CPU-only consumers see no behaviour change beyond the type rename —
backend == VMAF_BACKEND_HANDLE_NONE,backend_handle == 0. - Score values — v1 and v2 score paths share the same extractor implementations; numerical equivalence is preserved.
Cross-references¶
- ADR-0928 — decision record + alternatives + consequences.
- ADR-0186 — first hwaccel-import case that motivated typed backend handles.
- ADR-0700 — VMAFX rebrand directory move; aligned with v2 lifecycle.
- ADR-0709 — controller/node protocol that consumes v2.
- CLAUDE.md §12 r14 — FFmpeg-patches lockstep rule.
core/include/libvmaf/picture.h— v1 header (frozen).core/include/libvmaf/picture_v2.h— v2 design header.