github derekshreds/Snacks v2.13.0
Snacks v2.13.0

9 hours ago

Snacks v2.13.0

Automated Media Library Transcoder

A focused release adding WebM output, fixing a scratch-directory file-placement bug where in-place encodes were stranded on the scratch volume, and closing a hardware-routing deadlock where Auto + AV1 on a machine with non-AV1 hardware (e.g. Intel UHD 630) would queue jobs forever instead of falling back to CPU. WebM joins MKV and MP4 in the output-format picker; selecting it coerces the per-job codec to AV1 (libsvtav1) and audio to Opus per the WebM spec, with subtitles stripped — the user's saved settings are not modified, only the per-job clone. The placement fix routes the final move target through the original file's directory whenever EncodeDirectory (scratch) is set but OutputDirectory is not, so the encode lands next to the source instead of stranding on the scratch drive. The hardware-routing fix makes the local dispatcher's "has usable hardware?" check codec-aware, mirroring the existing cluster-router logic, so CPU stays eligible under Auto when no detected hardware encoder supports the requested codec — paired with a one-time-per-codec log line so the user gets a single visible explanation instead of silent slow encodes.


WebM output support

Container is a first-class third option alongside MKV and MP4

  • EncoderOptions.Format — doc updated to list "webm" as a valid value. WebM coerces the per-job options clone to AV1 video + Opus audio per the WebM spec; saved settings are untouched.
  • Snacks/Views/Shared/_GeneralSettings.cshtml — adds the "WebM (AV1 + Opus)" option to the Output Format dropdown. Helper text explains the WebM constraints up front so the user isn't surprised when their H.264 + AAC selection silently becomes AV1 + Opus.
  • Snacks/Views/Shared/_AppModals.cshtml — the per-folder / per-node override modal gains the same WebM option so cluster and folder-level overrides can pick the container too.

Coerce-to-AV1 at the per-job clone, never the saved settings

  • TranscodingService.CoerceForWebmAsync (new) — runs first thing in the convert path. When Format == "webm" and Codec != "av1", rewrites options.Codec = "av1" and options.Encoder = "libsvtav1" on the per-job clone and logs the coercion to the work-item log. The user's settings.json is untouched — this only mutates the per-dispatch options block, so an MP4/H.265 default remains intact for the next file. Subsequent stages (encoder resolution, MapAudio, BuildFfmpegCommand) see a self-consistent options object and don't need to special-case WebM.
  • Audio coercion lives in FfprobeService.MapAudio — pushing the audio decision through ResolveAudioCodec keeps the WebM-vs-MP4 rules in one place, rather than a second coercion call alongside the video one.

Container-aware helpers replace the isMatroska boolean

  • FfprobeService.IsMatroska / IsMp4 / IsWebm (new) — single-token container predicates. Three containers means the old boolean is no longer enough information; named predicates are cheaper to read than chained string comparisons at every call site.
  • FfprobeService.MapAudio container parameter — replaces bool isMatroska. MP4 forces non-MP4-safe codecs to AAC (existing behaviour); WebM is the new branch and forces non-WebM-safe codecs to Opus.
  • FfprobeService.MapSub container parameter — replaces bool isMatroska. Both MP4 and WebM emit -sn; only Matroska preserves text subtitles. The WebM spec defines a WebVTT path, but ffmpeg's webm muxer doesn't accept it cleanly across versions and the muxer-rejection failure mode is worse than just stripping subs.
  • FfprobeService.ContainerCanCopySource container parameter — replaces the MP4-implicit version. WebM allows only opus or vorbis source streams for -c:a copy; everything else re-encodes to Opus. Matroska stays permissive (any source codec can be copied).
  • FfprobeService.AudioCodecSpec.AllowedInWebm (new record field) — paired with the existing AllowedInMp4. Opus is the only WebM-allowed entry in the codec spec table today; aac/ac3/eac3 are explicitly disallowed. Storing it on the spec keeps the allow-list next to the codec definition rather than scattering container rules through the resolver.
  • FfprobeService.ResolveAudioCodec — the unknown-codec fallback now picks Opus for WebM and AAC otherwise. The container-mismatch branch (allowed-in-MP4 / allowed-in-WebM) emits a warning with the actual fallback codec name so the per-job log explains exactly what happened.

Format-aware muxer and extension routing

  • TranscodingService.FormatMuxer / FormatExtension (new internal statics) — single-source-of-truth helpers replacing the inline format == "mkv" ? "matroska" : "mp4" ternaries at five sites in TranscodingService plus VideoJobRouter.ExpectedOutputExtension. Maps mkv → matroska / .mkv, webm → webm / .webm, anything else → mp4 / .mp4 (the fallback matches the prior code's default branch so unknown tokens behave the same as before).
  • TranscodingService.BuildFfmpegCommand-movflags +faststart is now MP4-only (was previously "MP4 unless MKV"). WebM joins MKV in skipping faststart, which is a no-op for non-MP4 muxers but the previous code would have emitted it for WebM and produced a warning.
  • VideoJobRouter.ExpectedOutputExtension — delegates to FormatExtension so cluster routing computes the same WebM extension the local pipeline produces.

Tests

  • Snacks.Tests/Pipeline/FullCommandScenarioTests.cs — two new end-to-end scenarios and two helper-coverage facts:
    • Scenario 16 (Scenario_AV1_Opus_WebM_emits_webm_muxer_and_strips_subs): H.264 + AC3 + SRT source with Format=webm produces -c:v libsvtav1, -c:a:0 libopus (AC3 is re-encoded because it can't be copied into WebM), -sn (subs always stripped for WebM), and no +faststart.
    • Scenario 17 (Scenario_Opus_source_in_WebM_is_copied): AV1 + Opus source into WebM passes audio through as -c:a:0 copy — the one stream-copy-safe audio path for WebM.
    • FormatExtension_maps_each_known_container / FormatMuxer_maps_each_known_container: pin the three-way mapping and the unknown-token fallback.
  • Snacks.Tests/Video/RateControlAndScaleTests.csContainerCopyRows renamed to Mp4CopyRows; new WebmCopyRows theory pins the Opus/Vorbis-only WebM allow-list including case-insensitive matching; new ContainerCanCopySource_Matroska_is_permissive fact pins the permissive MKV branch.
  • Snacks.Tests/Audio/AudioPlannerTests.cs / Subtitles/SubtitleMappingTests.cs — mechanical migration from the isMatroska bool to container strings.

Hardware routing falls back to CPU when no device can encode the codec

Auto + AV1 on Intel UHD 630 no longer deadlocks the queue

  • TranscodingService.TryReserveLocalDeviceSlot — the local-master dispatcher's "is there hardware available?" check used to be devices.Any(d => d.DeviceId != "cpu") — codec-blind. On a machine with hardware that can't encode the requested codec (the canonical case: Intel UHD 630 + AV1), CPU was disqualified under Auto and the hardware device was disqualified by the codec match, leaving no eligible slot and the job stuck queued indefinitely. The replacement, hasHardwareThatCanEncode = devices.Any(d => d.DeviceId != "cpu" && DeviceCanEncode(d.DeviceId, workItem, options)), mirrors the cluster-router's existing predicate so the local and cluster paths agree on what "hardware available" means.
  • TranscodingService.IsDeviceEligibleUnderHwPref (new internal static) — extracted the four-rule hw-vs-CPU eligibility ladder out of TryReserveLocalDeviceSlot so it can be unit-tested without standing up a slot ledger or detected-device list. The rules themselves are unchanged in shape (none → CPU only; specific vendor → CPU excluded and family must match; auto+HW → CPU excluded), only "auto+HW" now means "auto with hardware that can encode this codec".
  • TranscodingService.CanDeviceEncodeCodec (new internal static) — extracted the codec-vs-device match out of the instance-bound DeviceCanEncode so tests can exercise it on synthetic HardwareDevice rows. CPU (a null device row) is treated as always-capable; unknown codecs return true so ffmpeg makes the call.

One-time-per-codec log when Auto lands on CPU

  • TranscodingService._loggedAutoCpuFallback (new field) + log emission in TryReserveLocalDeviceSlot — when a job lands on CPU under Auto (and Auto isn't CPU-by-design — i.e. there is a CPU-incompatible codec involved), the dispatcher logs one explanatory line per codec per process: No hardware encoder for '<codec>' on any detected device — using software fallback (<encoder>). Encodes will be significantly slower. Dedupe is per codec, so a queue full of AV1 files emits one line, not one per job. Resets on process restart so a config change that adds hardware support gets re-evaluated. Accessed under the existing scheduler semaphore — no separate lock. The motivating GitHub issue called out "no errors reported to help track down the cause"; this is the signal the user needed.

Tests

  • Snacks.Tests/Video/DeviceSlotSelectionTests.cs (new file) — pins both extracted predicates and their composition:
    • IsDeviceEligibleUnderHwPref rows cover every branch of the four-rule ladder, including the regression case ("cpu", "auto", hasHardwareThatCanEncode: false, expected: true).
    • CanDeviceEncodeCodec rows cover the codec key-mapping (hevc/h265h265, avc/h264h264), unknown-codec passthrough, and CPU-as-fallback.
    • End-to-end composition tests reproduce the original bug (Intel-no-AV1 + Auto + AV1 → CPU) and verify the regression-guard scenarios (Intel + Auto + H.264 still picks Intel, Specific-vendor + impossible codec still queues forever).

File placement no longer strands the encode on the scratch drive

Scratch + no OutputDirectory was leaving outputs on the scratch volume

  • TranscodingService.HandleOutputPlacement (in-place branch) — when OutputDirectory is not configured, the final destination is the original file's directory. The pre-fix code passed outputPath (which is in EncodeDirectory when scratch is configured) through GetCleanOutputName, which only stripped the [snacks] tag — it did not move the file off the scratch volume. Result: the user's encoded output lived on the scratch drive forever, even though the original was on the media library. The fix resolves originalDir = _fileService.GetDirectory(workItem.Path) and routes both the replace-original branch and the keep-both branch through that directory:
    • Replace-original branch: builds the clean name (no [snacks] tag) and joins it to originalDir, then moves the staged encode there.
    • Keep-both branch: when EncodeDirectory is set, moves the staged encode (with [snacks] tag intact) to originalDir and updates the local outputPath so the subsequent log lines reference the actual final location.
  • The fix is scoped to the no-OutputDirectory path — when the user has configured an explicit output location, that path is still honoured. The previous behaviour was technically correct only for the legacy "no scratch directory" layout, which became invisible once scratch was added in v2.11.

Files Changed

WebM support

  • Snacks/Models/EncoderOptions.csFormat xmldoc lists "webm" as a valid value
  • Snacks/Services/FfprobeService.csIsMatroska / IsMp4 / IsWebm predicates; MapAudio / MapSub / ContainerCanCopySource take a container string; AudioCodecSpec.AllowedInWebm; ResolveAudioCodec WebM/Opus branch
  • Snacks/Services/TranscodingService.csCoerceForWebmAsync; FormatMuxer / FormatExtension; BuildFfmpegCommand MP4-only +faststart; all format == "mkv" ternaries routed through the helpers
  • Snacks/Services/Routing/VideoJobRouter.csExpectedOutputExtension delegates to FormatExtension
  • Snacks/Views/Shared/_GeneralSettings.cshtml — WebM dropdown option + helper text
  • Snacks/Views/Shared/_AppModals.cshtml — WebM in the per-folder/per-node override dropdown
  • Snacks.Tests/Pipeline/FullCommandScenarioTests.cs — Scenarios 16 / 17; FormatExtension / FormatMuxer mapping facts
  • Snacks.Tests/Video/RateControlAndScaleTests.csMp4CopyRows (renamed) + new WebmCopyRows + Matroska permissive fact
  • Snacks.Tests/Audio/AudioPlannerTests.csisMatroskacontainer migration
  • Snacks.Tests/Subtitles/SubtitleMappingTests.csisMatroskacontainer migration

Hardware routing

  • Snacks/Services/TranscodingService.csIsDeviceEligibleUnderHwPref / CanDeviceEncodeCodec extracted; TryReserveLocalDeviceSlot codec-aware hasHardwareThatCanEncode; _loggedAutoCpuFallback + one-time-per-codec log
  • Snacks.Tests/Video/DeviceSlotSelectionTests.cs — eligibility-ladder + codec-match + composition tests

File placement

  • Snacks/Services/TranscodingService.csHandleOutputPlacement in-place branch routes via _fileService.GetDirectory(workItem.Path)

Version bumps

  • Snacks/Controllers/HomeController.cs — health endpoint version
  • Snacks/Services/ClusterDiscoveryService.csClusterVersion protocol bump to 2.13.0
  • Snacks/Views/Shared/_Layout.cshtml — footer version
  • README.md — version badge
  • build-and-export.bat — Docker tag version
  • electron-app/package.json / package-lock.json — version

Full documentation: README.md

Don't miss a new Snacks release

NewReleases is sending notifications on new releases.