Files
ersatztv/docs/superpowers/specs/2026-07-20-qsv-native-decode-design.md
T
timothyandClaude Opus 4.8 cdbb685d22 docs(498): fold Fable review fixes into plan + spec
- 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>
2026-07-20 21:53:16 +02:00

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: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:

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_QsvPreferNativeDecoderQsvPreferNativeDecoder 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):
    <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).