Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion SentryDeck.Tests/PackageManagerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()
{
Expand Down
2 changes: 1 addition & 1 deletion SentryDeck/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -1893,7 +1893,7 @@
<!-- Playback notes -->
<TextBlock Text="Playback notes"
Style="{StaticResource AboutSectionHeader}" />
<TextBlock Text="FFmpeg 8.1 shared binaries are required for playback. Use the Download FFmpeg button when prompted."
<TextBlock Text="FFmpeg 9.0 shared binaries are required for playback. Use the Download FFmpeg button when prompted."
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<TextBlock Text="If clips do not appear, confirm the folder contains a TeslaCam directory."
Expand Down
21 changes: 15 additions & 6 deletions SentryDeck/Playback/FlyleafCameraPlayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -67,7 +67,7 @@ public async Task<bool> OpenAsync(string path)
path,
defaultPlaylistItem: true,
defaultVideo: true,
defaultAudio: _player.Config.Audio.Enabled,
defaultAudio: false,
defaultSubtitles: false,
forceSubtitles: false));

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -196,7 +198,14 @@ public void Dispose()
_player.Dispose();
}

private static Config CreateConfig(bool audioEnabled)
/// <summary>
/// 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.
/// </summary>
private static Config CreateConfig()
{
var config = new Config
{
Expand All @@ -211,7 +220,7 @@ private static Config CreateConfig(bool audioEnabled)
},
Audio =
{
Enabled = audioEnabled,
Enabled = false,
},
Subtitles =
{
Expand Down
4 changes: 2 additions & 2 deletions SentryDeck/Playback/VideoPlayerController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ public VideoPlayerController(

/// <summary>
/// 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.
/// </summary>
public static VideoPlayerController Create(IReadOnlyList<(string Camera, FlyleafHost Host)> cameras)
{
Expand All @@ -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);
}
Expand Down
6 changes: 3 additions & 3 deletions SentryDeck/SentryDeck.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@

<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageReference Include="Flyleaf.FFmpeg.Bindings" Version="8.0.1" />
<PackageReference Include="FlyleafLib" Version="3.10.4" />
<PackageReference Include="FlyleafLib.Controls.WPF" Version="1.6.3" />
<PackageReference Include="Flyleaf.FFmpeg.Bindings" Version="9.0.0" />
<PackageReference Include="FlyleafLib" Version="3.11.3" />
<PackageReference Include="FlyleafLib.Controls.WPF" Version="1.7.3" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Debug" Version="3.0.0" />
Expand Down
4 changes: 4 additions & 0 deletions SentryDeck/Services/FlyleafRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
36 changes: 35 additions & 1 deletion SentryDeck/Services/PackageManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ namespace SentryDeck;
/// </summary>
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;
Expand Down Expand Up @@ -125,6 +125,8 @@ public static async Task DownloadAndExtractFFmpeg()
destinationBinPath,
extractedFileCount,
stopwatch.ElapsedMilliseconds);

RemoveSupersededFFmpegDirectories(FFmpegInstallRoot, FFmpegBinFolderName);
}
finally
{
Expand All @@ -135,6 +137,38 @@ public static async Task DownloadAndExtractFFmpeg()
}
}

/// <summary>
/// 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.
/// </summary>
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;
}

/// <summary>
/// Returns the installed FFmpeg bin directory, or null when it is missing.
/// </summary>
Expand Down