- Blocker 1: VA-API decode to SOFTWARE frames (drop -hwaccel_output_format, new DecoderVaapiToSoftware) so the proven hwupload/vpp_qsv branch bridges to the QSV encoder — the naive hardware-surface path emits a bare vpp_qsv on VA-API frames and fails on ~all content. - Blocker 2: bool? domain property + != false coercion (DeinterlaceVideo pattern) so create-with-false actually persists false. - High 3: REST DTOs bool?=null + ?? true for /api/v1 additive-compat. - Medium 4: correct Task 4 test scaffolding (DefaultHardwareCapabilities). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
Design: VA-API decode on the QSV pipeline (ersatztv#498)
Date: 2026-07-20 Issue: ersatztv#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:236computes onehwAccel; it is passed twice intoFFmpegState(decoder + encoder slots) at:567-568, and once toGetBuilder(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: addpublic bool QsvPreferNativeDecoder { get; set; }; set= trueinNew().- Migration:
scripts/add-migration.sh Add_FFmpegProfile_QsvPreferNativeDecoder→QsvPreferNativeDecoder INTEGER/tinyint NOT NULL DEFAULT 1in both Sqlite and MySql projects, plus bothTvContextModelSnapshot.cs. Default 1 realizes the "default ON" decision for existing rows.
2. FFmpeg pipeline (substantive)
ErsatzTV.FFmpeg/FFmpegState.cs: addbool QsvPreferNativeDecoderto the record;falsein 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 threenew FFmpegState(...)sites (the same wayVaapiDevice/VaapiDriverare already read and threaded). Only relevant whenhwAccel == Qsv.ErsatzTV.FFmpeg/GlobalOption/HardwareAcceleration/QsvHardwareAccelerationOption.cs: when the flag is set, emit-hwaccel vaapi -hwaccel_output_format vaapiin 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,SetDecoderselects the generic VA-API decoder (reuseDecoderVaapi— the single codec-agnostic decoder thatVaapiPipelineBuilder.SetDecoderuses for all codecs,VaapiPipelineBuilder.cs:140), and the filter/accel-state setup inserts a VA-API→QSVhwmapso the decoded VA-API surfaces reach the*_qsvencoder GPU-resident. Highest-risk spot — exacthwmap/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.csrecords and their handlers write the field. No new validation.- Regenerate OpenAPI: build the app project first, then
./scripts/update-openapi.sh, thennpm run generate:api(updatesv1.json,docs/endpoint-index.md,web/src/api/generated/v1.d.ts).
4. SPA
web/src/screens/FFmpegProfilesScreen.tsx: add, rendered only whenhardwareAcceleration === 'Qsv'(next to the QSV device / extra-frames rows):<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 defaultqsvPreferNativeDecoder: 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 1means 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.csand/orErsatzTV.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@vadevice chain, a VA-API decoder, and a*_qsvencoder. - flag off ⇒ today's
-hwaccel qsv -hwaccel_output_format qsv+*_qsvdecoder (regression guard).
- flag on ⇒ emitted args contain
- 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.mdas applicable. - OpenAPI regen updates
docs/endpoint-index.md+v1.jsonautomatically. - No route change →
docs/blazor-route-parity.mduntouched.
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:
-
Decode produces SOFTWARE frames, not GPU VA-API surfaces. The QSV builder's
SetScale/SetDeinterlaceemit a barevpp_qsv/deinterlace_qsvon hardware frames, which cannot consume VA-API surfaces (ffmpeg won't auto-hwmap). So the native path emits-hwaccel vaapiwithout-hwaccel_output_format vaapi(frames download to system memory) and uses a newDecoderVaapiToSoftware(software output). Frames then flow through the existing, provenformat=nv12,hwupload=…,vpp_qsv/deinterlace_qsvbranch — the same graph the production error-loop paths (software-decode + QSV-encode) already run. This is a PCIe round-trip, NOT Jellyfin's zero-copyhwmap=derive_device=qsv(that filter doesn't exist here; out of scope). We still get the tolerant decoder + QSV encoder, which is the point. -
The domain property is
bool?, and REST DTOs arebool? = null. A non-nullablebool+HasDefaultValue(true)makes EF silently persisttruewhen a user creates a profile with the box unchecked (CLR-defaultfalsereads as "unset"). Use the repo'sDeinterlaceVideopattern:bool?property,HasDefaultValue(true), effective value read with!= false(null-means-ON). REST request DTOs usebool? = null+?? trueso an old client omitting the field can't flip the feature off (/api/v1additive-compat).
Out of scope (explicitly not doing)
- A general
DecodeHardwareAccelerationenum 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).