diff --git a/SentryDeck.Tests/PackageManagerTests.cs b/SentryDeck.Tests/PackageManagerTests.cs index ecd1c31..4eb1a36 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; @@ -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/MainWindow.xaml b/SentryDeck/MainWindow.xaml index 571ed48..55bff56 100644 --- a/SentryDeck/MainWindow.xaml +++ b/SentryDeck/MainWindow.xaml @@ -1893,7 +1893,7 @@ - OpenAsync(string path) path, defaultPlaylistItem: true, defaultVideo: true, - defaultAudio: _player.Config.Audio.Enabled, + defaultAudio: false, defaultSubtitles: false, forceSubtitles: false)); @@ -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; @@ -196,7 +198,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 +220,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/SentryDeck.csproj b/SentryDeck/SentryDeck.csproj index aa52269..aa53695 100644 --- a/SentryDeck/SentryDeck.csproj +++ b/SentryDeck/SentryDeck.csproj @@ -8,9 +8,9 @@ - - - + + + 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, diff --git a/SentryDeck/Services/PackageManager.cs b/SentryDeck/Services/PackageManager.cs index a1554b3..09eb324 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; @@ -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. ///