Skip to content

build(ffmpeg): software AV1 via dav1d, with the decoder chosen per path - #40

Merged
bilipp merged 3 commits into
mainfrom
feature/av1-dav1d
Sep 29, 2026
Merged

bilipp merged 3 commits into
mainfrom
feature/av1-dav1d

Conversation

@bilipp

@bilipp bilipp commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Summary

AV1 played as a black picture with sound on Apple TV (DVprofile10.mkv: AV1 Main 10, 4K, Dolby Vision profile 10.1).

FFmpeg's native AV1 decoder only drives hardware. Without an hwaccel it returns ENOSYS for every packet (libavcodec/av1dec.c), and only Apple's newest chips decode AV1 in hardware. The build had no other AV1 decoder, so the video lane failed while audio played on. Lume's engine fallback never triggered, because the session stayed .playing on audio.

This links dav1d for software AV1 and picks the right decoder for each path. Two commits:

build(ffmpeg): link dav1d for software AV1 decoding

  • build/scripts/build-dav1d.sh cross-compiles dav1d 1.5.4 (VideoLAN, BSD-2-Clause) with meson for every slice: static, NEON/NASM assembly on, tools and tests off.
    • meson's host system stays darwin for every slice, because dav1d's own checks only recognise darwin/ios/tvos; visionOS would otherwise miss the Mach-O symbol prefix its assembly needs. subsystem carries the real platform.
  • build-ffmpeg.sh builds dav1d first and links it with --enable-libdav1d. pkg-config is pinned (PKG_CONFIG_LIBDIR) to this slice's dav1d only, never a host Homebrew copy. libdav1d.a lands next to FFmpeg's libraries, so make-xcframework.sh merges it into libffmpeg.a.
  • build/scripts/fetch-sources.sh downloads every pinned tarball in versions.json and checks its SHA-256. dav1d's matches VideoLAN's published checksum. CI uses it in place of three copies of the FFmpeg-only download step, and installs meson/ninja where it builds slices.

fix(decode): choose the AV1 decoder per path instead of by registration

  • With dav1d linked, avcodec_find_decoder(AV1) returns libdav1d, which is registered ahead of the native decoder and has no VideoToolbox hwaccel. Keeping that lookup would have traded the black picture for losing AV1 hardware decoding on chips that have it.
  • VideoDecoder.decoder(for:hardware:) walks the registered decoders in order, with experimental ones last, as avcodec_find_decoder does:
    • hardware path: the first decoder with a VideoToolbox device config;
    • software path: the first decoder that can run without hardware, skipping AV_CODEC_CAP_HARDWARE and the native av1 decoder. FFmpeg sets no capability flag on it, but it is hwaccel-only by implementation.
  • The AV1 hardware path is only opened when VTIsHardwareDecodeSupported(kCMVideoCodecType_AV1) says the chip has an AV1 decoder, because the native decoder has no software fallback of its own. Every other codec resolves exactly as before.

Artifact URL (follow-up after the tag)

Package.swift still points at the v0.2.0 artifact, which has no dav1d. The new checksum can only exist once a tagged release has built the xcframework, so the bump follows the tag, as #36 did for v0.2.0. Until then, consumers resolving the package by URL keep the old binary, and AV1 fails there exactly as it does today; the decoder selection falls back correctly without dav1d. CI builds its own FFmpeg (the cache key covers build/scripts/** and versions.json), so this PR's tests run against dav1d.

Type of change

  • fix — bug fix
  • feat — new capability
  • perf — performance improvement
  • refactor — neither fixes a bug nor adds a feature
  • docs — documentation only (setup steps in AGENTS.md / README / CONTRIBUTING, the new decoder-choice invariant in AGENTS.md)
  • test — adding or fixing tests
  • build / chore — build pipeline, tooling, or maintenance

Invariants

The only data-plane change is which AVCodec VideoDecoder.setupCodec opens; it still runs on the decode thread.

  • No blocking FFmpeg call was added to an actor, and no stage bypasses Channel.
  • No rebuild-in-place: a new URL is still a new session object.
  • Raw container timestamps still stop at TimestampUnwrapper.
  • A/V sync is still owned by AVSampleBufferRenderSynchronizer (no new heuristics or clocks).
  • No force unwraps, as!, fatalError, or assertionFailure in engine paths; failures are typed.
  • No new global mutable state; configuration still flows through PlayerConfiguration.
  • No control flow derived from av_log strings. The hardware-only check reads the decoder's name and capability flags, never log output.

Licensing

  • dav1d is BSD-2-Clause. It's permissive and compatible with the LGPL build, and adds no relinking requirement.
  • THIRD-PARTY-NOTICES.md has a new dav1d section and table row, and adds the one new obligation for anyone shipping a binary: reproduce dav1d's copyright notice.
  • The licence text is in LICENSES/dav1d-BSD-2-Clause.txt, a verbatim copy of COPYING from the 1.5.4 tarball. dav1d is unmodified; no patches are applied.
  • The build stays LGPL. All eight locally built slices configure as:
License: LGPL version 2.1 or later

Verification

  • swift build is warning-free. Nothing from this branch warns. Render/SystemRenderer.swift:63-64 has two main-actor isolation warnings that are not touched here.
  • swift test is green, against a locally built xcframework with dav1d:
Test run with 90 tests in 14 suites passed after 35.884 seconds.

Each commit passes on its own; the build commit alone gives 89 tests.

  • I added or updated tests for this change.
    • DecoderTests "video: AV1 decodes with or without AV1 hardware" checks that decoding reaches end of stream without .failed and that frames are monotonic and stay 10-bit. It runs on VideoToolbox exactly when the host chip has an AV1 decoder, and through dav1d otherwise.
    • Negative control: against the pre-dav1d xcframework the test fails (no frames, no end of stream).
  • New media fixtures were added to generate-fixtures.sh: av1.mkv, AV1 Main 10-bit via SVT-AV1, which Homebrew's ffmpeg ships and CI already installs. No binary media committed.

Platforms verified

  • macOS: tests, plus the throughput check below
  • iOS / iPadOS: builds and links (device and arm64 simulator). Not run.
  • tvOS: builds and links (device and arm64 simulator). Playback on Apple TV not yet verified.
  • visionOS: builds and links (device and arm64 simulator). Not run.

generic/platform=iOS Simulator and tvOS Simulator fail to link for x86_64 locally, on core FFmpeg symbols (_av_buffer_ref…). That's because the local build has no x86_64 simulator slices; it's not caused by this change. The release job builds all 10 slices.

Real-world content

  • MKV, AV1 Main 10-bit 4K (3840×1920, 23.976 fps, ~11 Mbps), Dolby Vision profile 10.1 (HDR10-compatible base layer), E-AC-3 Atmos audio, local VOD.
    • Before: black picture, audio fine.
    • After: decodes through dav1d at 176.6 fps in the engine's software path on an M1 Pro (release build), delivered as 10-bit x420 buffers.
  • For scale, host ffmpeg -c:v libdav1d on the same file decodes at 75 fps with 2 threads. The Apple TV 4K (A15) should comfortably exceed 24 fps, but hasn't been measured.

bilipp and others added 3 commits September 29, 2026 22:10
AV1 played as a black picture with sound on Apple TV (DVprofile10.mkv,
AV1 Main 10 4K). FFmpeg's native AV1 decoder only drives hardware —
without an hwaccel it returns ENOSYS for every packet (av1dec.c) — and
only Apple's newest chips decode AV1 in hardware. With no other AV1
decoder in the build, the video lane failed while audio played on.

build-dav1d.sh cross-compiles dav1d 1.5.4 (VideoLAN, BSD-2-Clause) with
meson for each slice: static, NEON / NASM assembly on, tools and tests
off. meson's host system stays 'darwin' for every slice because dav1d's
own checks only know darwin/ios/tvos, and visionOS would otherwise miss
the Mach-O symbol prefix its assembly needs; `subsystem` carries the
real platform. build-ffmpeg.sh builds it first, links it with
--enable-libdav1d through a pkg-config LIBDIR that sees only this
slice's dav1d (never a host Homebrew copy), and drops libdav1d.a next to
FFmpeg's libraries so make-xcframework.sh merges it into libffmpeg.a.

fetch-sources.sh downloads every pinned tarball in versions.json and
checks its SHA-256 (dav1d's matches VideoLAN's published sum). CI uses
it instead of three copies of the FFmpeg-only download step, and
installs meson/ninja where it builds slices. versions.json, the notices
(new dav1d section, the BSD-2 attribution obligation for binaries,
LICENSES/dav1d-BSD-2-Clause.txt verbatim) and the contributor docs move
with it. The build stays LGPL: all eight local slices configure as
"License: LGPL version 2.1 or later".

Package.swift still points at the v0.2.0 artifact, which has no dav1d:
its checksum can only exist once a release has built the new
xcframework, so the bump follows the tag, as it did for v0.2.0 (#36).
With dav1d linked, avcodec_find_decoder(AV1) returns libdav1d — it is
registered ahead of FFmpeg's native decoder — and libdav1d has no
VideoToolbox hwaccel. Keeping that lookup would have traded the black
picture for losing AV1 hardware decoding on the chips that have it.
The two paths need different decoders: the native `av1` is the only
one that drives VideoToolbox, but cannot decode without it; libdav1d
decodes in software and only there.

VideoDecoder.decoder(for:hardware:) walks the registered decoders in
order (experimental ones last, like avcodec_find_decoder) and takes,
for the hardware path, the first with a VideoToolbox device config and,
for the software path, the first that runs without hardware — skipping
AV_CODEC_CAP_HARDWARE and the native AV1 decoder, which FFmpeg flags as
nothing but is hwaccel-only by implementation. The AV1 hardware path is
only opened when VTIsHardwareDecodeSupported says the chip has it,
since that decoder has no software fallback of its own. Everything else
resolves exactly as before.

A new av1.mkv fixture (AV1 Main 10-bit via SVT-AV1, which Homebrew's
ffmpeg ships) checks that AV1 decodes on any host — on VideoToolbox
exactly when the chip has an AV1 decoder, through dav1d otherwise — and
stays 10-bit. Against the pre-dav1d xcframework the test fails: no
frames, no end of stream.

DVprofile10.mkv (AV1 4K 10-bit, DV profile 10.1) through the engine's
software path: 176.6 fps on an M1 Pro, P010 out.
@bilipp
bilipp merged commit 5905f19 into main Sep 29, 2026
1 of 4 checks passed
@bilipp
bilipp deleted the feature/av1-dav1d branch September 29, 2026 20:51
bilipp added a commit that referenced this pull request Sep 29, 2026
CI has been red since 069a818, and #39/#40 added two more failures of
the same kind. The workflow asks for Xcode_26.app, which the macos-15
image no longer has; the fallback selects Xcode 26.3 — Swift 6.2 —
while local development runs Xcode 27 (Swift 6.4). Code that 6.4
accepts at once, 6.2 rejects:

- ChannelDrain.value: a bare `result` inside the initializer of the
  local `result` binds to that local on 6.2 ("initializer for
  conditional binding must have Optional type"). Now `self.result`.
- Matrix3.multiply/inverse: nested `reduce(0)` closures over untyped
  literals exceed 6.2's solver ("unable to type-check this expression
  in reasonable time"). Plain loops with explicit types.
- DolbyVisionTests.extraction: `#expect(pieces == [.polynomial(...)])`
  took 6.4 2.4 s to type-check; the expected values are now typed
  locals. HEVCBaseLayerFilterTests' hvcC array builder (0.1 s) is
  a loop.

Found by compiling with -warn-long-expression-type-checking=25 on 6.4:
nothing is above 40 ms any more, the level the existing tests already
passed CI at. Behaviour is unchanged. The suite passes apart from the intermittent
SRT cue test, a real duplicate-cue bug fixed in the next commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant