Snacks v2.14.1
Automated Media Library Transcoder
A point release covering two production fixes on top of v2.14.0. The headline is a GPU hardware-acceleration overhaul on Linux: AMD Radeon GPUs are now detected as a real amd device instead of being mislabelled intel (which stranded AMD users — an explicit "AMD" preference matched nothing, so jobs sat Pending forever), and an explicit vendor pick that can't serve the requested codec — the vendor isn't detected, or is present but lacks that codec (e.g. AV1 on a pre-RDNA3 Radeon) — now falls back to a software encode instead of stalling the queue indefinitely. The same fallback rule is mirrored on the cluster router, the fallback is finally logged (to the actual log file and per-item in the UI, not just stdout where Serilog never saw it), and flipping the hardware-acceleration setting now wakes the queue correctly. The second fix makes the macOS app self-contained: the bundled ffmpeg/ffprobe used to link against Homebrew dylibs under /opt/homebrew/… and died with dyld: Library not loaded (exit 134) on any machine without Homebrew — the build now relocates every transitive dylib into the app so end users need nothing installed.
GPU hardware acceleration on Linux
AMD Radeon is detected as an amd device, not mislabelled intel
Linux auto-detection probed every /dev/dri/renderD* node through the Intel/QSV path and registered whatever VAAPI it found as DeviceId="intel". On an AMD box that meant the device existed but answered to the wrong name — so a user who explicitly selected AMD in Hardware Acceleration matched no device and every job stuck at Pending.
TranscodingService.ReadRenderNodeVendor(new internal static) — maps a render node to a Snacks device family by reading its PCI vendor id from sysfs (/sys/class/drm/{node}/device/vendor):0x1002→amd,0x8086→intel,0x10de→nvidia. Returnsnull(and never throws) when the file is missing or the id is unrecognised — Windows/macOS, a GPU-less container, or an unknown vendor — and callers treatnullas "probe the historical Intel/QSV path".- Per-vendor probe routing in the Linux detection loop — each render node is now dispatched by its actual vendor:
- NVIDIA nodes are skipped here (they don't expose VAAPI/QSV encode) and left to the dedicated NVENC probe, so a CUDA card is no longer wasted on VAAPI probes or mislabelled.
- AMD nodes probe VAAPI through Mesa's
radeonsidriver (falling back to libva's own auto-detection if that doesn't take), testhevc_vaapi/h264_vaapi/av1_vaapi, and register asDeviceId="amd"with the vendor-agnostic*_vaapiencoders. QSV is not probed on AMD (it's Intel-only). - Intel / unknown nodes keep the historical QSV-first-then-VAAPI path unchanged (oneVPL still pins the
iHDdriver before probing).
LogVaapiDiagnosticsdriver selection —vainfonow picks itsLIBVA_DRIVER_NAMEby the node's actual vendor (radeonsifor AMD, otherwiseiHD), honouring an explicit host override first. ForcingiHDon a Radeon printed a driver-load error instead of the codec table, so the diagnostics were useless on exactly the machines that needed them.
A specific vendor that can't serve the codec now falls back to software
The eligibility ladder enforced "specific vendor ⇒ never CPU" unconditionally. So if you picked a vendor that couldn't encode the target codec — the device wasn't detected at all, or it was present but lacked that codec (the classic case: AV1 on a pre-RDNA3 Radeon, which has no hardware AV1 encoder) — the job had nowhere to go and waited forever. CPU is now an eligible software fallback in exactly that situation, so the queue keeps moving.
TranscodingService.IsDeviceEligibleUnderHwPref— gains aselectedVendorCanEncodeparameter. The unconditional specific-vendor ⇒ never CPU rule becomes specific-vendor ⇒ CPU only as a fallback when that vendor can't serve the codec (return !selectedVendorCanEncode). Thenone(software-only),auto, and wrong-family rules are unchanged.TranscodingService.TryReserveLocalDeviceSlot— computesselectedVendorCanEncode(the chosen vendor's device is present, enabled, and able to encode this item's codec) and passes it into the predicate. Importantly, a vendor device that can do the codec but is merely busy keepsselectedVendorCanEncodetrue, so the job waits for the GPU rather than silently spilling onto a slow software encode.VideoJobRouter.ScoreSlot(cluster path) — mirrors the local rule withselectedVendorUsable: the specific vendor ⇒ never CPU (-50) gate now only applies when that vendor is present, enabled, and codec-capable on that node; otherwise CPU scores positive as the software fallback, so a cluster job for a codec no node's selected vendor can do is no longer unschedulable.
The software fallback is now actually logged
When a job lands on CPU because the hardware can't encode the codec, that used to be reported only via Console.WriteLine — stdout, which Serilog never captured, so it never reached the log file the user actually reads. (The original GitHub issue that motivated the auto-fallback specifically called out "no errors reported to help track down the cause.")
TranscodingService.TryReserveLocalDeviceSlot— the CPU-fallback notice now fires for any non-nonepreference (bothautoand an explicit vendor), with a message tailored to each case (auto: no detected device does the codec; explicit vendor: that vendor is absent or has no hardware encoder for the codec). It is routed through the structured logger (_log.LogWarning) so it lands in the rolling log file, and emits a per-itemTranscodingLogline (viaLogAsync) so the reason shows on that item in the UI. The global log line is deduped per(vendor, codec)so a long queue of identical fallbacks doesn't spam the file; the per-item line is not deduped, so every affected item explains itself.
Flipping the hardware-acceleration setting wakes the queue
TranscodingService.ApplyOptions— a settings change now callsMarkQueueWindowDirty()instead of onlyWakeScheduler(). Flipping hardware acceleration can make rows the scheduler had previously rotated past as "locally unservable" servable again; without resetting the window rotation offset, those newly-servable items could keep getting skipped until some unrelated queue mutation cleared the offset. Marking the window dirty resets the offset and snaps the window back to the head.
Tests
Snacks.Tests/Cluster/VideoJobRouterScoreTests.cs(new) — AMD+AV1 when the AMD device lacks AV1 scores the CPU fallback positive; AMD+AV1 with no AMD device at all scores the CPU fallback positive; AMD+HEVC when the AMD device is capable scores the GPU and excludes CPU.Snacks.Tests/Video/DeviceSlotSelectionTests.cs(updated) — an explicit Intel/AMD/NVIDIA pick for a codec that vendor can't do (or with no such device present) falls back to CPU; a capable vendor still keeps CPU excluded (don't spill a doable job onto software); and the fallback goes to CPU specifically, never to a different vendor's hardware.Snacks.Tests/Video/HardwareEncoderTests.cs(updated) —ReadRenderNodeVendorreturnsnullfor a missing node and never throws.
macOS: self-contained ffmpeg/ffprobe (no Homebrew required)
The packaged Mac app no longer dies with dyld: Library not loaded
The Homebrew-built ffmpeg/ffprobe binaries link dynamically against dozens of dylibs under /opt/homebrew/… (libav*, libsw*, libx264, libx265, libvpx, libssl, …). Those absolute paths don't exist on an end user's machine, so the bundled binary died with dyld: Library not loaded: /opt/homebrew/… and ffmpeg exited with code 134 — the app effectively required the user to brew install ffmpeg themselves.
electron-app/scripts/bundle-ffmpeg-mac.sh(new) — resolves every transitive non-system dylib, copies it next to the executables, and rewrites all references — inside theffmpeg/ffprobeexecutables to@executable_path/<basename>, and inside the copied dylibs (both their dependency references and their own id) to@loader_path/<basename>— then re-signs everything (install_name_toolinvalidates ad-hoc signatures). It walks dependencies recursively and idempotently, resolves@rpath/@loader_path/@executable_pathreferences against the usual Homebrew locations, skips system libraries (/usr/lib,/System,/Library/Apple), and finishes with a hard verification pass that fails the build (exit 1) if any/opt/homebrewreference leaked through. Written for macOS's default bash 3.2 (no associative arrays — uses a space-padded basename list) and mirrors the existingbundle-ocr-mac.shrelocation pattern.build-mac.sh— after locating and prepping the ffmpeg binaries, now invokesbundle-ffmpeg-mac.sh electron-app/ffmpegso the packaged app ships fully self-contained.
Files Changed
GPU hardware acceleration (Linux vendor detection + software fallback)
Snacks/Services/TranscodingService.cs—ReadRenderNodeVendor(new); per-vendor Linux probe routing (AMDradeonsiVAAPI registeringDeviceId="amd", NVIDIA skipped, Intel/unknown unchanged);vainfodriver-by-vendor;IsDeviceEligibleUnderHwPrefgainsselectedVendorCanEncode;TryReserveLocalDeviceSlotcomputes it and logs the software fallback (structured log + per-itemTranscodingLog, deduped per vendor/codec);ApplyOptionsmarks the queue window dirtySnacks/Services/Routing/VideoJobRouter.cs—ScoreSlotmirrors the fallback rule withselectedVendorUsableSnacks.Tests/Cluster/VideoJobRouterScoreTests.cs— newSnacks.Tests/Video/DeviceSlotSelectionTests.cs— explicit-vendor CPU-fallback cases (updated)Snacks.Tests/Video/HardwareEncoderTests.cs—ReadRenderNodeVendornull/no-throw case (updated)
macOS self-contained ffmpeg
electron-app/scripts/bundle-ffmpeg-mac.sh— new dylib-relocation scriptbuild-mac.sh— invoke the bundling script during the Mac buildelectron-app/package-lock.json— refreshed transitive dev-dependency entries (form-data,hasown,js-yaml)
Version bumps
Snacks/Controllers/HomeController.cs— health endpoint versionSnacks/Services/ClusterDiscoveryService.cs—ClusterVersionprotocol bump to 2.14.1Snacks/Views/Shared/_Layout.cshtml— footer versionREADME.md— version badge and footerbuild-and-export.bat— Docker tag versionelectron-app/package.json/package-lock.json— version
Full documentation: README.md