From abb7333f78637f0a4fff37df841786f61a68bf4e Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 30 Aug 2026 00:26:14 -0500 Subject: [PATCH 1/4] Upgrade Flyleaf and the bundled FFmpeg to the 9.0 branch FlyleafLib 3.10.4 to 3.11.3, FlyleafLib.Controls.WPF 1.6.3 to 1.7.3, Flyleaf.FFmpeg.Bindings 8.0.1 to 9.0.0, and the build the app downloads from the n8.1 branch to n9.0. The four move together or not at all. FlyleafLib 3.11.3 depends on bindings 9.0.0, and those bindings P/Invoke avcodec-63, avformat-63 and avutil-61, which only the 9.0 shared build ships; leaving the download on 8.1 would hand Flyleaf a set of libraries it cannot load. The About pane and the archive-root constant in the tests name the branch too, so both follow. Verified against the real download rather than the constant: the app fetches ffmpeg-n9.0-latest-win64-gpl-shared-9.0.zip, extracts avcodec-63, avformat-63, avutil-61, avfilter-12, swresample-7 and swscale-10, and the engine starts on them. 370 tests green, and both the x64 and arm64 self-contained single-file publishes CI produces still succeed. On its own this bump breaks any clip that carries an audio track; the next commit removes audio playback, which is what makes it safe. --- SentryDeck.Tests/PackageManagerTests.cs | 2 +- SentryDeck/MainWindow.xaml | 2 +- SentryDeck/SentryDeck.csproj | 6 +++--- SentryDeck/Services/PackageManager.cs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/SentryDeck.Tests/PackageManagerTests.cs b/SentryDeck.Tests/PackageManagerTests.cs index ecd1c31..459d9ab 100644 --- a/SentryDeck.Tests/PackageManagerTests.cs +++ b/SentryDeck.Tests/PackageManagerTests.cs @@ -5,7 +5,7 @@ namespace SentryDeck.Tests; public sealed class PackageManagerTests : IDisposable { - private const string ArchiveRoot = "ffmpeg-n8.1-latest-win64-gpl-shared-8.1"; + private const string ArchiveRoot = "ffmpeg-n9.0-latest-win64-gpl-shared-9.0"; private readonly string _root = Directory.CreateDirectory( Path.Combine(Path.GetTempPath(), $"SentryDeckTests-{Guid.NewGuid():N}")).FullName; diff --git a/SentryDeck/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 571ed48..55bff56 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -1893,7 +1893,7 @@ - - - - + + + diff --git a/SentryDeck/Services/PackageManager.cs b/SentryDeck/Services/PackageManager.cs index a1554b3..1736237 100644 --- a/SentryDeck/Services/PackageManager.cs +++ b/SentryDeck/Services/PackageManager.cs @@ -11,7 +11,7 @@ namespace SentryDeck; /// public static class PackageManager { - private const string FFmpegReleaseBranch = "8.1"; + private const string FFmpegReleaseBranch = "9.0"; private static readonly string FFmpegBinFolderName = $"ffmpeg-{FFmpegReleaseBranch}-bin"; private static string FFmpegInstallRoot => AppContext.BaseDirectory; From 6c47c2738cbd9f57f3691368b616af0cf98bc313 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 30 Aug 2026 09:28:01 -0500 Subject: [PATCH 2/4] Play every camera video-only Dashcam recordings carry no audio, the app has no volume or mute control, and four to six players sharing one timeline would each render their own copy of any track that did turn up. Audio was enabled on the primary camera and had been doing nothing useful. Turning it off also takes the app out of FlyleafLib 3.11's audio filter graph, which is where both of the defects this upgrade otherwise runs into live. The first is that 3.11.1 made avfilter mandatory for audio while the engine loads LoadProfile.Main, which never loads it. A clip whose front file carried an audio track died in AudioDecoder's static constructor with DllNotFoundException, and the damage was silent: the front camera advanced 1.0s of media in 8.2s of wall clock while the three audio-free side cameras ran normally, and every speed change on it threw into the debug-level catch in ApplyPlaybackSpeed. The second is a deadlock. AudioDecoder.Flush runs under lockActions and lockCodecCtx, OnSpeedChanged runs under lockSpeed, and both rebuild the filter graph, so a speed change overlapping a seek wedges the seek thread inside avfilter_graph_free and the next Player.Play spins forever waiting for it on the UI thread. Two process dumps a minute apart showed the same pair of stacks, and it is a regression: the same loop runs 8 of 8 iterations clean on 3.10.4 and hangs on iteration 2 on 3.11.3. Both were fixed here first, with LoadProfile.Filters and a seek-settled gate around the speed setter. Not opening the audio stream at all removes them at the root instead, and removes 43 lines of gate with them. Verified with footage that does carry an audio track, so this holds regardless of what a recording turns out to contain: avfilter is no longer loaded into the process at all, the audio stream is not opened, playback runs at real time, and the sequence that used to hang on the second iteration runs 40 of 40 clean. Trimmed exports are untouched, because ClipExporter stream-copies whatever the source holds; the harness still sees aac in the exported file. 370 tests green, 79 harness checks green. --- SentryDeck/Playback/FlyleafCameraPlayer.cs | 17 ++++++++++++----- SentryDeck/Playback/VideoPlayerController.cs | 4 ++-- SentryDeck/Services/FlyleafRuntime.cs | 4 ++++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/SentryDeck/Playback/FlyleafCameraPlayer.cs b/SentryDeck/Playback/FlyleafCameraPlayer.cs index 8752363..3e1efdb 100644 --- a/SentryDeck/Playback/FlyleafCameraPlayer.cs +++ b/SentryDeck/Playback/FlyleafCameraPlayer.cs @@ -17,10 +17,10 @@ internal sealed class FlyleafCameraPlayer : ICameraPlayer private bool _isOpen; private bool _isStopping; - public FlyleafCameraPlayer(FlyleafHost host, bool audioEnabled) + public FlyleafCameraPlayer(FlyleafHost host) { _host = host ?? throw new ArgumentNullException(nameof(host)); - _player = new Player(CreateConfig(audioEnabled)); + _player = new Player(CreateConfig()); // All shortcuts are app-wide and act on every camera at once; Flyleaf's default bindings (space, arrows, …) would pause/seek only the player whose surface has focus. // Must run AFTER the Player ctor: KeysConfig.SetPlayer force-loads the defaults into any empty binding list, so clearing the config up front is undone (and RemoveAll on a fresh config NREs, since Keys is null until SetPlayer runs). @@ -67,7 +67,7 @@ public async Task OpenAsync(string path) path, defaultPlaylistItem: true, defaultVideo: true, - defaultAudio: _player.Config.Audio.Enabled, + defaultAudio: false, defaultSubtitles: false, forceSubtitles: false)); @@ -196,7 +196,14 @@ public void Dispose() _player.Dispose(); } - private static Config CreateConfig(bool audioEnabled) + /// + /// Audio is disabled on every camera, so a clip that carries an audio track is opened video-only. + /// Dashcam recordings have no audio, the app exposes no volume or mute control, and four to six players sharing one timeline would each render their own copy of any track that did turn up. + /// It also keeps the app clear of FlyleafLib 3.11's audio filter graph, which has two defects this app would otherwise have to work around: under LoadProfile.Main the audio decoder's static constructor dies on the missing avfilter and drops that camera to roughly an eighth of real-time playback, and a speed change overlapping a seek wedges the seek thread inside avfilter_graph_free and freezes the UI thread behind it. + /// Turning audio back on means dealing with both again, and moving FlyleafRuntime to LoadProfile.Filters at the same time. + /// Trimmed exports are unaffected: ClipExporter stream-copies whatever the source holds, audio included. + /// + private static Config CreateConfig() { var config = new Config { @@ -211,7 +218,7 @@ private static Config CreateConfig(bool audioEnabled) }, Audio = { - Enabled = audioEnabled, + Enabled = false, }, Subtitles = { diff --git a/SentryDeck/Playback/VideoPlayerController.cs b/SentryDeck/Playback/VideoPlayerController.cs index 0836ba5..4267e1e 100644 --- a/SentryDeck/Playback/VideoPlayerController.cs +++ b/SentryDeck/Playback/VideoPlayerController.cs @@ -126,7 +126,7 @@ public VideoPlayerController( /// /// Creates the app's Flyleaf-backed playback controller from one surface per camera. - /// The primary camera (front when present) drives the timeline and owns the audio track. + /// The primary camera (front when present) drives the timeline; playback is video-only on every camera. /// public static VideoPlayerController Create(IReadOnlyList<(string Camera, FlyleafHost Host)> cameras) { @@ -143,7 +143,7 @@ public static VideoPlayerController Create(IReadOnlyList<(string Camera, Flyleaf var players = cameras.ToDictionary( camera => camera.Camera, - camera => (ICameraPlayer)new FlyleafCameraPlayer(camera.Host, audioEnabled: camera.Camera == primaryCamera)); + camera => (ICameraPlayer)new FlyleafCameraPlayer(camera.Host)); return new VideoPlayerController(players, primaryCamera); } diff --git a/SentryDeck/Services/FlyleafRuntime.cs b/SentryDeck/Services/FlyleafRuntime.cs index b6de254..1081da2 100644 --- a/SentryDeck/Services/FlyleafRuntime.cs +++ b/SentryDeck/Services/FlyleafRuntime.cs @@ -32,6 +32,10 @@ public bool TryStart() Engine.Start(new EngineConfig { FFmpegPath = directory, + + // Main leaves avfilter unloaded, which is only safe because FlyleafCameraPlayer disables audio on every player. + // FlyleafLib 3.11.1 routes all audio through an avfilter graph, so one audio-enabled player opening a clip that has an audio track would die in the audio decoder's static constructor and drop that camera to a fraction of real-time playback, silently. + // Turning audio back on means moving to LoadProfile.Filters at the same time. FFmpegLoadProfile = LoadProfile.Main, FFmpegLogLevel = Flyleaf.FFmpeg.LogLevel.Warn, LogLevel = FlyleafLib.LogLevel.Warn, From 9c557a845237d0477f1df215086de4a861d354db Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 30 Aug 2026 00:27:52 -0500 Subject: [PATCH 3/4] Reclaim FFmpeg builds left by superseded release branches Every FFmpeg release branch installs into a folder of its own next to the app, so moving the download from 8.1 to 9.0 leaves ffmpeg-8.1-bin sitting there, about 200MB unpacked, with nothing that will ever load it again and no code path that would ever remove it. The same was already true of the 7.1 folder before it, so the cost compounds with every branch bump. A successful install now deletes sibling ffmpeg-*-bin folders that are not the current one. It runs only after the new binaries are in place, and a folder that refuses to delete is logged rather than failing the install, because the app is fully usable either way and a stale folder is a disk-space problem, not a correctness one. The match has to stay narrow, since the install root is the app's own folder: a test covers that logs, runtimes and ffmpeg-notes are all left alone, alongside one that covers the delete itself. The directory list is materialized before the first delete rather than enumerated lazily while entries disappear from under it. Verified end to end rather than only through the tests: seeded a stale ffmpeg-8.1-bin, deleted the current folder to force a real download, and confirmed only ffmpeg-9.0-bin remained afterwards. 374 tests green. --- SentryDeck.Tests/PackageManagerTests.cs | 36 +++++++++++++++++++++++++ SentryDeck/Services/PackageManager.cs | 34 +++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/SentryDeck.Tests/PackageManagerTests.cs b/SentryDeck.Tests/PackageManagerTests.cs index 459d9ab..4eb1a36 100644 --- a/SentryDeck.Tests/PackageManagerTests.cs +++ b/SentryDeck.Tests/PackageManagerTests.cs @@ -82,6 +82,42 @@ public void ExtractFFmpegBin_RejectsEntriesThatEscapeTheDestination() Directory.GetFiles(_root, "evil.dll", SearchOption.AllDirectories).ShouldBeEmpty(); } + [Fact] + public void RemoveSupersededFFmpegDirectories_DeletesOtherBranchesAndKeepsTheCurrentOne() + { + // Each FFmpeg release branch installs into a folder of its own, so bumping the branch strands the previous build (about 200 MB unpacked) next to the app with nothing that will ever load it again. + var installRoot = Directory.CreateDirectory(Path.Combine(_root, "install")).FullName; + Directory.CreateDirectory(Path.Combine(installRoot, "ffmpeg-9.0-bin")); + Directory.CreateDirectory(Path.Combine(installRoot, "ffmpeg-8.1-bin")); + Directory.CreateDirectory(Path.Combine(installRoot, "ffmpeg-7.1-bin")); + File.WriteAllText(Path.Combine(installRoot, "ffmpeg-8.1-bin", "avcodec-62.dll"), "stale"); + + var removed = PackageManager.RemoveSupersededFFmpegDirectories(installRoot, "ffmpeg-9.0-bin"); + + removed.ShouldBe(2); + Directory.Exists(Path.Combine(installRoot, "ffmpeg-9.0-bin")).ShouldBeTrue(); + Directory.Exists(Path.Combine(installRoot, "ffmpeg-8.1-bin")).ShouldBeFalse(); + Directory.Exists(Path.Combine(installRoot, "ffmpeg-7.1-bin")).ShouldBeFalse(); + } + + [Fact] + public void RemoveSupersededFFmpegDirectories_LeavesEverythingElseAlone() + { + // The install root is the app's own folder, so the match has to stay narrow enough that nothing but an FFmpeg install can ever be caught by it. + var installRoot = Directory.CreateDirectory(Path.Combine(_root, "install")).FullName; + Directory.CreateDirectory(Path.Combine(installRoot, "ffmpeg-9.0-bin")); + Directory.CreateDirectory(Path.Combine(installRoot, "logs")); + Directory.CreateDirectory(Path.Combine(installRoot, "ffmpeg-notes")); + Directory.CreateDirectory(Path.Combine(installRoot, "runtimes")); + + var removed = PackageManager.RemoveSupersededFFmpegDirectories(installRoot, "ffmpeg-9.0-bin"); + + removed.ShouldBe(0); + Directory.Exists(Path.Combine(installRoot, "logs")).ShouldBeTrue(); + Directory.Exists(Path.Combine(installRoot, "ffmpeg-notes")).ShouldBeTrue(); + Directory.Exists(Path.Combine(installRoot, "runtimes")).ShouldBeTrue(); + } + [Fact] public void ExtractFFmpegBin_ClearsAnExistingDestination() { diff --git a/SentryDeck/Services/PackageManager.cs b/SentryDeck/Services/PackageManager.cs index 1736237..09eb324 100644 --- a/SentryDeck/Services/PackageManager.cs +++ b/SentryDeck/Services/PackageManager.cs @@ -125,6 +125,8 @@ public static async Task DownloadAndExtractFFmpeg() destinationBinPath, extractedFileCount, stopwatch.ElapsedMilliseconds); + + RemoveSupersededFFmpegDirectories(FFmpegInstallRoot, FFmpegBinFolderName); } finally { @@ -135,6 +137,38 @@ public static async Task DownloadAndExtractFFmpeg() } } + /// + /// Deletes FFmpeg folders left behind by earlier release branches. + /// Every branch installs into a folder of its own next to the app, so an upgrade otherwise leaves the previous build (about 200 MB unpacked) sitting there forever with nothing that will ever load it again. + /// Only runs after a successful install, and a folder that refuses to delete is logged rather than failing the install: the new binaries are already in place and usable. + /// + internal static int RemoveSupersededFFmpegDirectories(string installRoot, string currentBinFolderName) + { + var removedCount = 0; + + // Materialized before the first delete: enumerating a directory while removing entries from it is not something to rely on. + foreach (var directory in Directory.GetDirectories(installRoot, "ffmpeg-*-bin")) + { + if (string.Equals(Path.GetFileName(directory), currentBinFolderName, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + try + { + Directory.Delete(directory, recursive: true); + removedCount++; + Log.Information("Removed superseded FFmpeg binaries. Directory={Directory}", directory); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Log.Warning(ex, "Failed to remove superseded FFmpeg binaries. Directory={Directory}", directory); + } + } + + return removedCount; + } + /// /// Returns the installed FFmpeg bin directory, or null when it is missing. /// From 948b62dd217350d13adf76fd5036f4576e4abdf4 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Sun, 30 Aug 2026 09:04:42 -0500 Subject: [PATCH 4/4] Record what 3.11 actually fixed in the backward frame-step workaround StepFrameAsync implements backward stepping as a small accurate seek because Flyleaf's own ShowFramePrev is a silent no-op on our ffconcat playlists. 3.11.0 lists a fix for a locking issue between seek and frame stepping, which reads like the workaround can now go. It cannot, and the comment says so with numbers, because the obvious test to reach for gives the wrong answer. Measured on the same clip and the same harness, calling the library directly, eight attempts per cell: ShowFramePrev moved 3.10.4 3.11.3 footage with an audio track 0/8 8/8 footage with no audio track 0/8 0/8 following ShowFrameNext moved 3.10.4 3.11.3 footage with no audio track 0/8 8/8 So 3.11 repaired the second half of the old failure: a no-op backward step no longer poisons the decoder, and the next forward step advances exactly one frame again. The backward step itself still does nothing on footage without an audio track, which is the footage this app opens. The trap is that it does work on 3.11.3 when the sample file happens to carry audio, so testing against the wrong clip makes the workaround look like dead code. The comment now carries that explicitly, so the next attempt starts from the measurement instead of the release note. --- SentryDeck/Playback/FlyleafCameraPlayer.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SentryDeck/Playback/FlyleafCameraPlayer.cs b/SentryDeck/Playback/FlyleafCameraPlayer.cs index 3e1efdb..9409258 100644 --- a/SentryDeck/Playback/FlyleafCameraPlayer.cs +++ b/SentryDeck/Playback/FlyleafCameraPlayer.cs @@ -168,7 +168,9 @@ public Task StepFrameAsync(bool forward) else { // Do NOT "simplify" this back to Flyleaf's ShowFramePrev. - // On our ffconcat (FFmpeg concat demuxer) playlists it is a silent no-op -- CurTime never moves, nothing throws -- and it poisons the decoder so the NEXT ShowFrameNext jumps ahead by 0.5s+ instead of one frame (verified against real footage, 2026-07). + // On our ffconcat (FFmpeg concat demuxer) playlists it is a silent no-op -- CurTime never moves, nothing throws -- and on FlyleafLib 3.10.4 it also poisoned the decoder so the NEXT ShowFrameNext jumped ahead by 0.5s+ instead of one frame (verified against real footage, 2026-07). + // Re-measured on 3.11.3 after its seek and frame-stepping lock fix (2026-08): the poisoning is gone, and a following ShowFrameNext advances exactly one frame again, but the backward step itself still moved 0 of 8 attempts on footage with no audio track, which is the footage this app opens. + // It does step correctly on 3.11.3 when the media happens to carry an audio track, so a quick test on the wrong sample file will suggest this workaround is unnecessary; it is not. // Backward stepping is therefore a small accurate seek: one frame duration back, padded by a PTS-rounding guard so the seek reliably presents the PREVIOUS frame rather than re-presenting the current one. // Accurate seeks are proven reliable on these playlists, including while paused. var fps = _player.Video?.FPS ?? 0;