# Design: VA-API decode on the QSV pipeline (ersatztv#498)

Date: 2026-07-20
Issue: [ersatztv#498](http://192.168.1.95:3000/timothy/ersatztv/issues/498)
Status: approved design, pending spec review

## Problem

`FFmpegProfile.HardwareAcceleration` is a single value applied to **both** decode and
encode. On Intel hardware, choosing `Qsv` opts into the QSV *decoder*, which is materially
less tolerant of imperfect H.264 than the VA-API decoder. On the reporter's i7-10700K this
produced ~50% channel cold-start failures ("Error splitting the input into NAL units",
`h264_qsv` decoder-open failures) on otherwise-playable content.

Jellyfin, on identical hardware, avoids this by decoding with VA-API and encoding with QSV.
That combination is not currently expressible in ErsatzTV.

### Reverification of the issue's claims (2026-07-20)

Confirmed against the code:

- `FFmpegLibraryProcessService.cs:236` computes one `hwAccel`; it is passed **twice** into
  `FFmpegState` (decoder + encoder slots) at `:567-568`, and once to `GetBuilder(hwAccel, …)`
  at `:594`, which selects a **single** pipeline builder for both stages. So decode and
  encode share one value at the profile/entry level. ✅
- `QsvPipelineBuilder.Init` (`:108-116`) hardcodes decode to QSV-or-software; `SetDecoder`
  (`:125-135`) has only QSV and software paths — no VA-API decode path. So VA-API decode +
  QSV encode is genuinely not expressible today. ✅ (understated in the issue)
- Listed schema fields exist (`FFmpegProfile.cs:12-16`). ✅

**Key nuance the issue author did not know:** the FFmpeg layer already models decode and
encode as **separate** modes (`FFmpegState.DecoderHardwareAccelerationMode` /
`EncoderHardwareAccelerationMode`, `FFmpegState.cs:7-8`), and the error-loop paths at
`FFmpegLibraryProcessService.cs:782-785` / `:923-926` already run **software decode +
hardware encode** in production. Moreover `QsvHardwareAccelerationOption.cs:37-38` already
builds a VA-API device and derives the QSV device from it (`-init_hw_device vaapi=va:<dev>`
then `qsv=hw@va`) — the exact device chaining Jellyfin uses (`qsv=qs@va`). The only thing
forcing QSV decode is `-hwaccel qsv -hwaccel_output_format qsv` at `QsvHardwareAccelerationOption.cs:21-22`.

**Field observations not verifiable here** (no Intel host): Jellyfin's captured command,
the 50%/6-cold-start failure rate, and iHD 25.2.3 behavior. Plausible and consistent with
known QSV-decoder strictness; treated as reported evidence, not code facts.

## How Jellyfin models this (the reference we are copying)

Jellyfin exposes a single checkbox under the QSV acceleration mode: **"Prefer OS native
DXVA or VA-API hardware decoders"** — **default ON**, **decode-only** (encoder stays QSV).
Jellyfin's stated rationale matches the issue: *"The DXVA and VA-API decoders are FFmpeg's
native hwaccel, are more error-tolerant than the QSV decoder wrapper, and support Dolby
Vision metadata."* Jellyfin deliberately does **not** offer a decode-family dropdown; it
collapses the choice to one default-on boolean.

Sources:
- <https://forum.jellyfin.org/t-qsv-prefer-os-native-dxva-or-va-api-hardware-decoders> (default-on, decode-only, tolerance + Dolby Vision rationale)
- <https://jellyfin.org/docs/general/post-install/transcoding/hardware-acceleration/intel/>

## Decisions

| Decision | Choice | Rationale |
|---|---|---|
| Config shape | Single boolean per profile, QSV-scoped | Mirror Jellyfin exactly; a decode-family enum would be *more* configurable than the tool we copy (rejected as over-building — the issue's Option C). |
| Default | **ON** (`true`) | Native decode is strictly more tolerant; "the working config is the default" for a reliability fix; Dolby Vision requires it. Existing QSV profiles adopt the hybrid on upgrade. |
| Field name | `QsvPreferNativeDecoder` | Borrows Jellyfin's "prefer … native … decoder" framing; `Qsv` prefix matches existing `QsvExtraHardwareFrames` and signals when it applies; "Prefer" honestly conveys fallback. |
| UI label | `Prefer native decoder` | Row only renders under QSV, so no prefix needed. |
| UI help | `Decode with the more error-tolerant VA-API decoder instead of QSV, while still encoding with QSV. Recommended on Intel — handles imperfect streams and is required for Dolby Vision.` | What / why / the one gotcha. |
| Builder strategy | Contained to the QSV builder + one `FFmpegState` bool | Do **not** touch `PipelineBuilderFactory`'s single-builder dispatch or repurpose the decode/encode-mode fields into a cross-family selector (that is the rejected Option C). Consistent with how `VaapiDevice` is already threaded into the QSV path. |

## Components (every layer touched)

### 1. Domain & migration
- `ErsatzTV.Core/Domain/FFmpegProfile.cs`: add `public bool QsvPreferNativeDecoder { get; set; }`;
  set `= true` in `New()`.
- Migration: `scripts/add-migration.sh Add_FFmpegProfile_QsvPreferNativeDecoder` →
  `QsvPreferNativeDecoder INTEGER/tinyint NOT NULL DEFAULT 1` in **both** Sqlite and MySql
  projects, plus both `TvContextModelSnapshot.cs`. Default 1 realizes the "default ON" decision
  for existing rows.

### 2. FFmpeg pipeline (substantive)
- `ErsatzTV.FFmpeg/FFmpegState.cs`: add `bool QsvPreferNativeDecoder` to the record;
  `false` in the two None/None error-loop fallbacks so behavior there is unchanged.
- `ErsatzTV.Core/FFmpeg/FFmpegLibraryProcessService.cs`: read the flag off the profile and
  pass it into the three `new FFmpegState(...)` sites (the same way `VaapiDevice`/`VaapiDriver`
  are already read and threaded). Only relevant when `hwAccel == Qsv`.
- `ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs`: when
  the flag is set, emit `-hwaccel vaapi -hwaccel_output_format vaapi` in place of
  `-hwaccel qsv -hwaccel_output_format qsv`, **keeping** the existing derived-device chain
  (`-init_hw_device vaapi=va:<dev>`, `-init_hw_device qsv=hw@va`, `-filter_hw_device hw`).
- `ErsatzTV.FFmpeg/Pipeline/QsvPipelineBuilder.cs`: when the flag is set, `SetDecoder` selects
  the generic VA-API decoder (reuse `DecoderVaapi` — the single codec-agnostic decoder that
  `VaapiPipelineBuilder.SetDecoder` uses for all codecs, `VaapiPipelineBuilder.cs:140`), and
  the filter/accel-state setup inserts a VA-API→QSV `hwmap`
  so the decoded VA-API surfaces reach the `*_qsv` encoder GPU-resident.
  **Highest-risk spot** — exact `hwmap`/filter placement is driven test-first against emitted
  args (see Testing). The encoder path is unchanged.

### 3. Application / API
- `ErsatzTV.Application/FFmpegProfiles/FFmpegProfileViewModel.cs`: add the field.
- `ErsatzTV.Application/FFmpegProfiles/Mapper.cs`: map at both sites (L15-19 and L57-61).
- `ErsatzTV.Application/FFmpegProfiles/Commands/CreateFFmpegProfile.cs` +
  `UpdateFFmpegProfile.cs` records and their handlers write the field. No new validation.
- Regenerate OpenAPI: build the app project first, then `./scripts/update-openapi.sh`, then
  `npm run generate:api` (updates `v1.json`, `docs/endpoint-index.md`, `web/src/api/generated/v1.d.ts`).

### 4. SPA
- `web/src/screens/FFmpegProfilesScreen.tsx`: add, rendered only when
  `hardwareAcceleration === 'Qsv'` (next to the QSV device / extra-frames rows):
  ```tsx
  <Row control={200} label="Prefer native decoder"
       help="Decode with the more error-tolerant VA-API decoder instead of QSV, while still encoding with QSV. Recommended on Intel — handles imperfect streams and is required for Dolby Vision.">
    <Checkbox checked={draft.qsvPreferNativeDecoder}
              onChange={(qsvPreferNativeDecoder) => set({ qsvPreferNativeDecoder })} />
  </Row>
  ```
- `web/src/api/ffmpegProfiles.ts`: draft default `qsvPreferNativeDecoder: true`; generated types
  come from the OpenAPI regen.

## Data flow

```
FFmpegProfilesScreen (checkbox, default true)
  → POST/PUT /api/…/ffmpeg-profiles  (Create/UpdateFFmpegProfile)
    → FFmpegProfile.QsvPreferNativeDecoder (DB column, default 1)
      → FFmpegLibraryProcessService: read flag → FFmpegState.QsvPreferNativeDecoder
        → QsvHardwareAccelerationOption: -hwaccel vaapi … (when true) | -hwaccel qsv … (when false)
        → QsvPipelineBuilder.SetDecoder: VA-API decoder + VA-API→QSV hwmap (when true)
        → encoder: h264_qsv / hevc_qsv (unchanged, both paths)
```

## Error handling / edge cases

- Flag has effect **only** on the QSV pipeline. Ignored for None/VAAPI/NVENC/AMF/VideoToolbox/etc.
- If VA-API decode cannot handle a codec, the existing software-decode fallback still applies
  ("prefer", not "force").
- Error-loop / offline paths keep `QsvPreferNativeDecoder = false` (unchanged behavior).
- `default 1` means existing QSV profiles switch to the hybrid on upgrade — intended; the
  hybrid is the more-robust path, not a riskier one.

## Testing & verification

- **NUnit arg-level tests** (deterministic seam; extend `ErsatzTV.FFmpeg.Tests/PipelineBuilderBaseTests.cs`
  and/or `ErsatzTV.Scanner.Tests/Core/FFmpeg/TranscodingTests.cs`):
  - flag **on** ⇒ emitted args contain `-hwaccel vaapi -hwaccel_output_format vaapi`, the
    `-init_hw_device vaapi=va:…` + `qsv=hw@va` device chain, a VA-API decoder, and a
    `*_qsv` encoder.
  - flag **off** ⇒ today's `-hwaccel qsv -hwaccel_output_format qsv` + `*_qsv` decoder (regression guard).
- **Honest verification limit:** true hardware validation needs an Intel QSV host. It cannot be
  exercised on this Mac (VideoToolbox only) or amd64 CI without QSV passthrough. The automated
  net is the emitted-command assertion; real-device confirmation is on the reporter's i7-10700K
  after deploy. This will be stated in the PR, not claimed as a passing live-E2E.

## Docs (same PR)

- `docs/decisions.md`: append — why default-ON, why a QSV boolean rather than a decode-family enum.
- FFmpeg-profile field reference: `docs/channels.md` / `docs/domain-model.md` as applicable.
- OpenAPI regen updates `docs/endpoint-index.md` + `v1.json` automatically.
- No route change → `docs/blazor-route-parity.md` untouched.

## Post-review revisions (Fable, 2026-07-20)

An independent Fable review of the implementation plan corrected two design details; the plan is authoritative, but recording them here so this spec doesn't mislead:

1. **Decode produces SOFTWARE frames, not GPU VA-API surfaces.** The QSV builder's `SetScale`/`SetDeinterlace` emit a *bare* `vpp_qsv`/`deinterlace_qsv` on hardware frames, which cannot consume VA-API surfaces (ffmpeg won't auto-`hwmap`). So the native path emits `-hwaccel vaapi` **without** `-hwaccel_output_format vaapi` (frames download to system memory) and uses a new `DecoderVaapiToSoftware` (software output). Frames then flow through the existing, proven `format=nv12,hwupload=…,vpp_qsv`/`deinterlace_qsv` branch — the same graph the production error-loop paths (software-decode + QSV-encode) already run. This is a PCIe round-trip, NOT Jellyfin's zero-copy `hwmap=derive_device=qsv` (that filter doesn't exist here; out of scope). We still get the tolerant decoder + QSV encoder, which is the point.

2. **The domain property is `bool?`, and REST DTOs are `bool? = null`.** A non-nullable `bool` + `HasDefaultValue(true)` makes EF silently persist `true` when a user creates a profile with the box unchecked (CLR-default `false` reads as "unset"). Use the repo's `DeinterlaceVideo` pattern: `bool?` property, `HasDefaultValue(true)`, effective value read with `!= false` (null-means-ON). REST request DTOs use `bool? = null` + `?? true` so an old client omitting the field can't flip the feature off (`/api/v1` additive-compat).

## Out of scope (explicitly not doing)

- A general `DecodeHardwareAcceleration` enum column / second dropdown (issue's Option C) —
  more configurable than Jellyfin; revisit only if a second asymmetric case (e.g. a
  non-QSV decode/encode split) actually appears.
- Changing `PipelineBuilderFactory`'s single-builder dispatch.
- Windows DXVA native decode (ErsatzTV's QSV path is VA-API-backed / Linux in practice).
