diff --git a/FFMpegCore.Extensions.Downloader/Enums/FFMpegBinaries.cs b/FFMpegCore.Extensions.Downloader/Enums/FFMpegBinaries.cs new file mode 100644 index 0000000..c3be69f --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Enums/FFMpegBinaries.cs @@ -0,0 +1,9 @@ +namespace FFMpegCore.Extensions.Downloader.Enums; + +[Flags] +public enum FFMpegBinaries : ushort +{ + FFMpeg = 1, + FFProbe = 2, + FFPlay = 4 +} diff --git a/FFMpegCore.Extensions.Downloader/Enums/FFMpegVersions.cs b/FFMpegCore.Extensions.Downloader/Enums/FFMpegVersions.cs new file mode 100644 index 0000000..c9f5dd3 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Enums/FFMpegVersions.cs @@ -0,0 +1,39 @@ +using System.ComponentModel; + +namespace FFMpegCore.Extensions.Downloader.Enums; + +public enum FFMpegVersions : ushort +{ + [Description("https://ffbinaries.com/api/v1/version/latest")] + LatestAvailable, + + [Description("https://ffbinaries.com/api/v1/version/6.1")] + V6_1, + + [Description("https://ffbinaries.com/api/v1/version/5.1")] + V5_1, + + [Description("https://ffbinaries.com/api/v1/version/4.4.1")] + V4_4_1, + + [Description("https://ffbinaries.com/api/v1/version/4.2.1")] + V4_2_1, + + [Description("https://ffbinaries.com/api/v1/version/4.2")] + V4_2, + + [Description("https://ffbinaries.com/api/v1/version/4.1")] + V4_1, + + [Description("https://ffbinaries.com/api/v1/version/4.0")] + V4_0, + + [Description("https://ffbinaries.com/api/v1/version/3.4")] + V3_4, + + [Description("https://ffbinaries.com/api/v1/version/3.3")] + V3_3, + + [Description("https://ffbinaries.com/api/v1/version/3.2")] + V3_2 +} diff --git a/FFMpegCore.Extensions.Downloader/Enums/SupportedPlatforms.cs b/FFMpegCore.Extensions.Downloader/Enums/SupportedPlatforms.cs new file mode 100644 index 0000000..0378f3e --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Enums/SupportedPlatforms.cs @@ -0,0 +1,13 @@ +namespace FFMpegCore.Extensions.Downloader.Enums; + +public enum SupportedPlatforms : ushort +{ + Windows64, + Windows32, + Linux64, + Linux32, + LinuxArmhf, + LinuxArmel, + LinuxArm64, + Osx64 +} diff --git a/FFMpegCore.Extensions.Downloader/Exceptions/FFMpegDownloaderException.cs b/FFMpegCore.Extensions.Downloader/Exceptions/FFMpegDownloaderException.cs new file mode 100644 index 0000000..8355c4c --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Exceptions/FFMpegDownloaderException.cs @@ -0,0 +1,18 @@ +namespace FFMpegCore.Extensions.Downloader.Exceptions; + +/// +/// Custom exception for FFMpegDownloader +/// +public class FFMpegDownloaderException : Exception +{ + public readonly string Detail = ""; + + public FFMpegDownloaderException(string message) : base(message) + { + } + + public FFMpegDownloaderException(string message, string detail) : base(message) + { + Detail = detail; + } +} diff --git a/FFMpegCore.Extensions.Downloader/Extensions/EnumExtensions.cs b/FFMpegCore.Extensions.Downloader/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..3336f11 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Extensions/EnumExtensions.cs @@ -0,0 +1,31 @@ +using System.ComponentModel; + +namespace FFMpegCore.Extensions.Downloader.Extensions; + +public static class EnumExtensions +{ + internal static string GetDescription(this Enum enumValue) + { + var field = enumValue.GetType().GetField(enumValue.ToString()); + if (field == null) + { + return enumValue.ToString(); + } + + if (Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) is DescriptionAttribute attribute) + { + return attribute.Description; + } + + return enumValue.ToString(); + } + + public static TEnum[] GetFlags(this TEnum input) where TEnum : Enum + { + return Enum.GetValues(input.GetType()) + .Cast() + .Where(input.HasFlag) + .Cast() + .ToArray(); + } +} diff --git a/FFMpegCore.Extensions.Downloader/FFMpegCore.Extensions.Downloader.csproj b/FFMpegCore.Extensions.Downloader/FFMpegCore.Extensions.Downloader.csproj new file mode 100644 index 0000000..bd18fb3 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/FFMpegCore.Extensions.Downloader.csproj @@ -0,0 +1,18 @@ + + + + true + FFMpeg downloader extension for FFMpegCore + 5.0.0 + ../nupkg + + + ffmpeg ffprobe convert video audio mediafile resize analyze download + Kerry Cao, Malte Rosenbjerg + + + + + + + diff --git a/FFMpegCore.Extensions.Downloader/FFMpegDownloader.cs b/FFMpegCore.Extensions.Downloader/FFMpegDownloader.cs new file mode 100644 index 0000000..f616c31 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/FFMpegDownloader.cs @@ -0,0 +1,83 @@ +using System.IO.Compression; +using System.Text.Json; +using FFMpegCore.Extensions.Downloader.Enums; +using FFMpegCore.Extensions.Downloader.Exceptions; +using FFMpegCore.Extensions.Downloader.Extensions; +using FFMpegCore.Extensions.Downloader.Models; + +namespace FFMpegCore.Extensions.Downloader; + +public static class FFMpegDownloader +{ + /// + /// Download the latest FFMpeg suite binaries for current platform + /// + /// used to explicitly state the version of binary you want to download + /// used to explicitly state the binaries you want to download (ffmpeg, ffprobe, ffplay) + /// used for specifying binary folder to download binaries into. If not provided, GlobalFFOptions are used + /// used to explicitly state the os and architecture you want to download + /// a list of the binaries that have been successfully downloaded + public static async Task> DownloadBinaries( + FFMpegVersions version = FFMpegVersions.LatestAvailable, + FFMpegBinaries binaries = FFMpegBinaries.FFMpeg | FFMpegBinaries.FFProbe, + FFOptions? options = null, + SupportedPlatforms? platformOverride = null) + { + using var httpClient = new HttpClient(); + + var versionInfo = await httpClient.GetVersionInfo(version); + var binariesDictionary = versionInfo.BinaryInfo?.GetCompatibleDownloadInfo(platformOverride) ?? + throw new FFMpegDownloaderException("Failed to get compatible download info"); + + var successList = new List(); + var relevantOptions = options ?? GlobalFFOptions.Current; + if (string.IsNullOrEmpty(relevantOptions.BinaryFolder)) + { + throw new FFMpegDownloaderException("Binary folder not specified"); + } + + var binaryFlags = binaries.GetFlags(); + foreach (var binaryFlag in binaryFlags) + { + if (binariesDictionary.TryGetValue(binaryFlag.ToString().ToLowerInvariant(), out var binaryUrl)) + { + using var zipStream = await httpClient.GetStreamAsync(new Uri(binaryUrl)); + var extracted = ExtractZipAndSave(zipStream, relevantOptions.BinaryFolder); + successList.AddRange(extracted); + } + } + + return successList; + } + + private static async Task GetVersionInfo(this HttpClient client, FFMpegVersions version) + { + var versionUri = version.GetDescription(); + + var response = await client.GetAsync(versionUri); + if (!response.IsSuccessStatusCode) + { + throw new FFMpegDownloaderException($"Failed to get version info from {versionUri}", "network error"); + } + + var jsonString = await response.Content.ReadAsStringAsync(); + var versionInfo = JsonSerializer.Deserialize(jsonString); + + return versionInfo ?? + throw new FFMpegDownloaderException($"Failed to deserialize version info from {versionUri}", jsonString); + } + + private static IEnumerable ExtractZipAndSave(Stream zipStream, string binaryFolder) + { + using var archive = new ZipArchive(zipStream, ZipArchiveMode.Read); + foreach (var entry in archive.Entries) + { + if (entry.Name is "ffmpeg" or "ffmpeg.exe" or "ffprobe.exe" or "ffprobe" or "ffplay.exe" or "ffplay") + { + var filePath = Path.Combine(binaryFolder, entry.Name); + entry.ExtractToFile(filePath, true); + yield return filePath; + } + } + } +} diff --git a/FFMpegCore.Extensions.Downloader/Models/BinaryInfo.cs b/FFMpegCore.Extensions.Downloader/Models/BinaryInfo.cs new file mode 100644 index 0000000..d02cb14 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Models/BinaryInfo.cs @@ -0,0 +1,74 @@ +using System.Runtime.InteropServices; +using System.Text.Json.Serialization; +using FFMpegCore.Extensions.Downloader.Enums; + +namespace FFMpegCore.Extensions.Downloader.Models; + +internal class BinaryInfo +{ + [JsonPropertyName("windows-64")] public Dictionary? Windows64 { get; set; } + + [JsonPropertyName("windows-32")] public Dictionary? Windows32 { get; set; } + + [JsonPropertyName("linux-32")] public Dictionary? Linux32 { get; set; } + + [JsonPropertyName("linux-64")] public Dictionary? Linux64 { get; set; } + + [JsonPropertyName("linux-armhf")] public Dictionary? LinuxArmhf { get; set; } + + [JsonPropertyName("linux-armel")] public Dictionary? LinuxArmel { get; set; } + + [JsonPropertyName("linux-arm64")] public Dictionary? LinuxArm64 { get; set; } + + [JsonPropertyName("osx-64")] public Dictionary? Osx64 { get; set; } + + /// + /// Automatically get the compatible download info for current os and architecture + /// + /// + /// + /// + /// + public Dictionary? GetCompatibleDownloadInfo(SupportedPlatforms? platformOverride = null) + { + if (platformOverride is not null) + { + return platformOverride switch + { + SupportedPlatforms.Windows64 => Windows64, + SupportedPlatforms.Windows32 => Windows32, + SupportedPlatforms.Linux64 => Linux64, + SupportedPlatforms.Linux32 => Linux32, + SupportedPlatforms.LinuxArmhf => LinuxArmhf, + SupportedPlatforms.LinuxArmel => LinuxArmel, + SupportedPlatforms.LinuxArm64 => LinuxArm64, + SupportedPlatforms.Osx64 => Osx64, + _ => throw new ArgumentOutOfRangeException(nameof(platformOverride), platformOverride, null) + }; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return RuntimeInformation.OSArchitecture == Architecture.X64 ? Windows64 : Windows32; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) + { + return RuntimeInformation.OSArchitecture switch + { + Architecture.X86 => Linux32, + Architecture.X64 => Linux64, + Architecture.Arm => LinuxArmhf, + Architecture.Arm64 => LinuxArm64, + _ => LinuxArmel + }; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX) && RuntimeInformation.OSArchitecture == Architecture.X64) + { + return Osx64; + } + + throw new PlatformNotSupportedException("Unsupported OS or Architecture"); + } +} diff --git a/FFMpegCore.Extensions.Downloader/Models/VersionInfo.cs b/FFMpegCore.Extensions.Downloader/Models/VersionInfo.cs new file mode 100644 index 0000000..ef24f62 --- /dev/null +++ b/FFMpegCore.Extensions.Downloader/Models/VersionInfo.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace FFMpegCore.Extensions.Downloader.Models; + +internal record VersionInfo +{ + [JsonPropertyName("version")] public string? Version { get; set; } + + [JsonPropertyName("permalink")] public string? Permalink { get; set; } + + [JsonPropertyName("bin")] public BinaryInfo? BinaryInfo { get; set; } +} diff --git a/FFMpegCore.Test/DownloaderTests.cs b/FFMpegCore.Test/DownloaderTests.cs new file mode 100644 index 0000000..d574230 --- /dev/null +++ b/FFMpegCore.Test/DownloaderTests.cs @@ -0,0 +1,53 @@ +using FFMpegCore.Extensions.Downloader; +using FFMpegCore.Extensions.Downloader.Enums; +using FFMpegCore.Test.Utilities; + +namespace FFMpegCore.Test; + +[TestClass] +public class DownloaderTests +{ + private FFOptions _ffOptions; + + [TestInitialize] + public void InitializeTestFolder() + { + var tempDownloadFolder = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(tempDownloadFolder); + _ffOptions = new FFOptions { BinaryFolder = tempDownloadFolder }; + } + + [TestCleanup] + public void DeleteTestFolder() + { + Directory.Delete(_ffOptions.BinaryFolder, true); + } + + [OsSpecificTestMethod(OsPlatforms.Windows | OsPlatforms.Linux)] + public async Task GetSpecificVersionTest() + { + var binaries = await FFMpegDownloader.DownloadBinaries(FFMpegVersions.V6_1, options: _ffOptions); + try + { + Assert.HasCount(2, binaries); + } + finally + { + binaries.ForEach(File.Delete); + } + } + + [OsSpecificTestMethod(OsPlatforms.Windows | OsPlatforms.Linux)] + public async Task GetAllLatestSuiteTest() + { + var binaries = await FFMpegDownloader.DownloadBinaries(options: _ffOptions); + try + { + Assert.HasCount(2, binaries); + } + finally + { + binaries.ForEach(File.Delete); + } + } +} diff --git a/FFMpegCore.Test/FFMpegCore.Test.csproj b/FFMpegCore.Test/FFMpegCore.Test.csproj index 9fa8034..47bb74b 100644 --- a/FFMpegCore.Test/FFMpegCore.Test.csproj +++ b/FFMpegCore.Test/FFMpegCore.Test.csproj @@ -24,6 +24,7 @@ + diff --git a/FFMpegCore.Test/Utilities/OsSpecificTestMethod.cs b/FFMpegCore.Test/Utilities/OsSpecificTestMethod.cs new file mode 100644 index 0000000..df7ebd5 --- /dev/null +++ b/FFMpegCore.Test/Utilities/OsSpecificTestMethod.cs @@ -0,0 +1,42 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using FFMpegCore.Extensions.Downloader.Extensions; + +namespace FFMpegCore.Test.Utilities; + +[Flags] +internal enum OsPlatforms : ushort +{ + Windows = 1, + Linux = 2, + MacOS = 4 +} + +internal class OsSpecificTestMethod : TestMethodAttribute +{ + private readonly IEnumerable _supportedOsPlatforms; + + public OsSpecificTestMethod(OsPlatforms supportedOsPlatforms, [CallerFilePath] string callerFilePath = "", + [CallerLineNumber] int callerLineNumber = -1) : base(callerFilePath, callerLineNumber) + { + _supportedOsPlatforms = supportedOsPlatforms.GetFlags() + .Select(flag => OSPlatform.Create(flag.ToString().ToUpperInvariant())) + .ToArray(); + } + + public override async Task ExecuteAsync(ITestMethod testMethod) + { + if (_supportedOsPlatforms.Any(RuntimeInformation.IsOSPlatform)) + { + return await base.ExecuteAsync(testMethod); + } + + var message = $"Test only executed on specific platforms: {string.Join(", ", _supportedOsPlatforms.Select(platform => platform.ToString()))}"; + { + return + [ + new TestResult { Outcome = UnitTestOutcome.Inconclusive, TestFailureException = new AssertInconclusiveException(message) } + ]; + } + } +} diff --git a/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs b/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs deleted file mode 100644 index 9a87749..0000000 --- a/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; - -namespace FFMpegCore.Test.Utilities; - -public class WindowsOnlyTestMethod : TestMethodAttribute -{ - public WindowsOnlyTestMethod([CallerFilePath] string callerFilePath = "", [CallerLineNumber] int callerLineNumber = -1) - : base(callerFilePath, callerLineNumber) - { - } - - public override async Task ExecuteAsync(ITestMethod testMethod) - { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - var message = "Test not executed on other platforms than Windows"; - { - return - [ - new TestResult { Outcome = UnitTestOutcome.Inconclusive, TestFailureException = new AssertInconclusiveException(message) } - ]; - } - } - - return await base.ExecuteAsync(testMethod); - } -} diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index 010ec44..7946552 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -93,7 +93,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] [DataRow(PixelFormat.Format24bppRgb)] [DataRow(PixelFormat.Format32bppArgb)] @@ -125,7 +125,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public void Video_ToMP4_Args_Pipe_DifferentImageSizes_WindowsOnly() { @@ -157,7 +157,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public async Task Video_ToMP4_Args_Pipe_DifferentImageSizes_WindowsOnly_Async() { @@ -189,7 +189,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public void Video_ToMP4_Args_Pipe_DifferentPixelFormats_WindowsOnly() { @@ -222,7 +222,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public async Task Video_ToMP4_Args_Pipe_DifferentPixelFormats_WindowsOnly_Async() { @@ -397,7 +397,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] [DataRow(PixelFormat.Format24bppRgb)] [DataRow(PixelFormat.Format32bppArgb)] @@ -446,7 +446,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] [DataRow(SKColorType.Rgb565)] [DataRow(SKColorType.Bgra8888)] @@ -483,7 +483,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] [DataRow(PixelFormat.Format24bppRgb)] [DataRow(PixelFormat.Format32bppArgb)] @@ -516,7 +516,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public void Video_Snapshot_InMemory_SystemDrawingCommon() { @@ -840,7 +840,7 @@ public class VideoTest } [SupportedOSPlatform("windows")] - [WindowsOnlyTestMethod] + [OsSpecificTestMethod(OsPlatforms.Windows)] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public void Video_TranscodeInMemory_WindowsOnly() { diff --git a/FFMpegCore.sln b/FFMpegCore.sln index 7ab0929..b99a44e 100644 --- a/FFMpegCore.sln +++ b/FFMpegCore.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.31005.135 +# Visual Studio Version 17 +VisualStudioVersion = 17.7.34003.232 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore", "FFMpegCore\FFMpegCore.csproj", "{19DE2EC2-9955-4712-8096-C22EF6713E4F}" EndProject @@ -13,6 +13,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore.Extensions.Syste EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore.Extensions.SkiaSharp", "FFMpegCore.Extensions.SkiaSharp\FFMpegCore.Extensions.SkiaSharp.csproj", "{5A76F9B7-3681-4551-A9B6-8D3AC5DA1090}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FFMpegCore.Extensions.Downloader", "FFMpegCore.Extensions.Downloader\FFMpegCore.Extensions.Downloader.csproj", "{5FA30158-CAB0-44FD-AD98-C31F5E3D5A56}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -39,6 +41,10 @@ Global {5A76F9B7-3681-4551-A9B6-8D3AC5DA1090}.Debug|Any CPU.Build.0 = Debug|Any CPU {5A76F9B7-3681-4551-A9B6-8D3AC5DA1090}.Release|Any CPU.ActiveCfg = Release|Any CPU {5A76F9B7-3681-4551-A9B6-8D3AC5DA1090}.Release|Any CPU.Build.0 = Release|Any CPU + {5FA30158-CAB0-44FD-AD98-C31F5E3D5A56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5FA30158-CAB0-44FD-AD98-C31F5E3D5A56}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5FA30158-CAB0-44FD-AD98-C31F5E3D5A56}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5FA30158-CAB0-44FD-AD98-C31F5E3D5A56}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/README.md b/README.md index 1394409..dcee337 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,12 @@ If you want to use `System.Drawing.Bitmap`s as `IVideoFrame`s, a `BitmapVideoFra # Binaries -## Installation +## Runtime Auto Installation +You can install a version of ffmpeg suite at runtime using `FFMpegDownloader.DownloadFFMpegSuite();` + +This feature uses the api from [ffbinaries](https://ffbinaries.com/api). + +## Manual Installation If you prefer to manually download them, visit [ffbinaries](https://ffbinaries.com/downloads) or [zeranoe Windows builds](https://ffmpeg.zeranoe.com/builds/).