diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c470186..c807872 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,9 +23,9 @@ jobs: uses: actions/checkout@v3 - name: Prepare .NET - uses: actions/setup-dotnet@v2 + uses: actions/setup-dotnet@v3 with: - dotnet-version: '6.0.x' + dotnet-version: '7.0.x' - name: Prepare FFMpeg uses: Iamshankhadeep/setup-ffmpeg@v1.2 @@ -34,4 +34,4 @@ jobs: version: "4.4" - name: Test with dotnet - run: dotnet test --logger GitHubActions + run: dotnet test FFMpegCore.sln --logger GitHubActions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7cf1425..0c7725b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,13 +8,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 - - name: Prepare .NET - uses: actions/setup-dotnet@v1 - with: - dotnet-version: '6.0.x' - - name: Build solution - run: dotnet build --output build -c Release - - name: Publish NuGet package - run: dotnet nuget push "build/*.nupkg" --source nuget.org --api-key ${{ secrets.NUGET_TOKEN }} + uses: actions/checkout@v3 + + - name: Prepare .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '7.0.x' + + - name: Build solution + run: dotnet pack FFMpegCore.sln --output build -c Release + + - name: Publish NuGet package + run: dotnet nuget push build/*.nupkg --source nuget.org --api-key ${{ secrets.NUGET_TOKEN }} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..caefd5d --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,15 @@ + + + netstandard2.0 + en + 5.0.0.0 + default + enable + + GitHub + https://github.com/rosenbjerg/FFMpegCore + https://github.com/rosenbjerg/FFMpegCore + MIT + en + + \ No newline at end of file diff --git a/FFMpegCore.Examples/FFMpegCore.Examples.csproj b/FFMpegCore.Examples/FFMpegCore.Examples.csproj index 68e7b5c..347607f 100644 --- a/FFMpegCore.Examples/FFMpegCore.Examples.csproj +++ b/FFMpegCore.Examples/FFMpegCore.Examples.csproj @@ -6,6 +6,7 @@ + diff --git a/FFMpegCore.Examples/Program.cs b/FFMpegCore.Examples/Program.cs index a718a21..e7b93e2 100644 --- a/FFMpegCore.Examples/Program.cs +++ b/FFMpegCore.Examples/Program.cs @@ -5,7 +5,7 @@ using FFMpegCore; using FFMpegCore.Enums; using FFMpegCore.Pipes; -using FFMpegCore.Extend; +using FFMpegCore.Extensions.System.Drawing.Common; var inputPath = "/path/to/input"; var outputPath = "/path/to/output"; @@ -34,7 +34,7 @@ { // process the snapshot in-memory and use the Bitmap directly - var bitmap = FFMpeg.Snapshot(inputPath, new Size(200, 400), TimeSpan.FromMinutes(1)); + var bitmap = FFMpegImage.Snapshot(inputPath, new Size(200, 400), TimeSpan.FromMinutes(1)); // or persists the image on the drive FFMpeg.Snapshot(inputPath, outputPath, new Size(200, 400), TimeSpan.FromMinutes(1)); @@ -61,7 +61,7 @@ await FFMpegArguments } { - FFMpeg.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1, + FFMpegImage.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1, ImageInfo.FromPath(@"..\1.png"), ImageInfo.FromPath(@"..\2.png"), ImageInfo.FromPath(@"..\3.png") @@ -83,9 +83,9 @@ await FFMpegArguments var inputImagePath = "/path/to/input/image"; { - FFMpeg.PosterWithAudio(inputPath, inputAudioPath, outputPath); - // or - var image = Image.FromFile(inputImagePath); + FFMpegImage.PosterWithAudio(inputPath, inputAudioPath, outputPath); + // or + using var image = Image.FromFile(inputImagePath); image.AddAudio(inputAudioPath, outputPath); } diff --git a/FFMpegCore/Extend/BitmapExtensions.cs b/FFMpegCore.Extensions.System.Drawing.Common/BitmapExtensions.cs similarity index 78% rename from FFMpegCore/Extend/BitmapExtensions.cs rename to FFMpegCore.Extensions.System.Drawing.Common/BitmapExtensions.cs index e2f5505..6633f69 100644 --- a/FFMpegCore/Extend/BitmapExtensions.cs +++ b/FFMpegCore.Extensions.System.Drawing.Common/BitmapExtensions.cs @@ -2,7 +2,7 @@ using System.Drawing; using System.IO; -namespace FFMpegCore.Extend +namespace FFMpegCore.Extensions.System.Drawing.Common { public static class BitmapExtensions { @@ -12,7 +12,7 @@ public static bool AddAudio(this Image poster, string audio, string output) poster.Save(destination); try { - return FFMpeg.PosterWithAudio(destination, audio, output); + return FFMpegImage.PosterWithAudio(destination, audio, output); } finally { diff --git a/FFMpegCore/Extend/BitmapVideoFrameWrapper.cs b/FFMpegCore.Extensions.System.Drawing.Common/BitmapVideoFrameWrapper.cs similarity index 98% rename from FFMpegCore/Extend/BitmapVideoFrameWrapper.cs rename to FFMpegCore.Extensions.System.Drawing.Common/BitmapVideoFrameWrapper.cs index 2222db6..2259fea 100644 --- a/FFMpegCore/Extend/BitmapVideoFrameWrapper.cs +++ b/FFMpegCore.Extensions.System.Drawing.Common/BitmapVideoFrameWrapper.cs @@ -7,7 +7,7 @@ using System.Threading.Tasks; using FFMpegCore.Pipes; -namespace FFMpegCore.Extend +namespace FFMpegCore.Extensions.System.Drawing.Common { public class BitmapVideoFrameWrapper : IVideoFrame, IDisposable { diff --git a/FFMpegCore.Extensions.System.Drawing.Common/FFMpegCore.Extensions.System.Drawing.Common.csproj b/FFMpegCore.Extensions.System.Drawing.Common/FFMpegCore.Extensions.System.Drawing.Common.csproj new file mode 100644 index 0000000..aafb577 --- /dev/null +++ b/FFMpegCore.Extensions.System.Drawing.Common/FFMpegCore.Extensions.System.Drawing.Common.csproj @@ -0,0 +1,21 @@ + + + + true + Image extension for FFMpegCore using System.Common.Drawing + 5.0.0 + + + ffmpeg ffprobe convert video audio mediafile resize analyze muxing + Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev + + + + + + + + + + + diff --git a/FFMpegCore.Extensions.System.Drawing.Common/FFMpegImage.cs b/FFMpegCore.Extensions.System.Drawing.Common/FFMpegImage.cs new file mode 100644 index 0000000..467fe6a --- /dev/null +++ b/FFMpegCore.Extensions.System.Drawing.Common/FFMpegImage.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using FFMpegCore.Enums; +using FFMpegCore.Helpers; +using FFMpegCore.Pipes; + +namespace FFMpegCore.Extensions.System.Drawing.Common +{ + public static class FFMpegImage + { + public static void ConversionSizeExceptionCheck(Image image) + => FFMpegHelper.ConversionSizeExceptionCheck(image.Size.Width, image.Size.Height); + + /// + /// Converts an image sequence to a video. + /// + /// Output video file. + /// FPS + /// Image sequence collection + /// Output video information. + public static bool JoinImageSequence(string output, double frameRate = 30, params ImageInfo[] images) + { + var tempFolderName = Path.Combine(GlobalFFOptions.Current.TemporaryFilesFolder, Guid.NewGuid().ToString()); + var temporaryImageFiles = images.Select((imageInfo, index) => + { + using var image = Image.FromFile(imageInfo.FullName); + FFMpegHelper.ConversionSizeExceptionCheck(image.Width, image.Height); + var destinationPath = Path.Combine(tempFolderName, $"{index.ToString().PadLeft(9, '0')}{imageInfo.Extension}"); + Directory.CreateDirectory(tempFolderName); + File.Copy(imageInfo.FullName, destinationPath); + return destinationPath; + }).ToArray(); + + var firstImage = images.First(); + try + { + return FFMpegArguments + .FromFileInput(Path.Combine(tempFolderName, "%09d.png"), false) + .OutputToFile(output, true, options => options + .ForcePixelFormat("yuv420p") + .Resize(firstImage.Width, firstImage.Height) + .WithFramerate(frameRate)) + .ProcessSynchronously(); + } + finally + { + Cleanup(temporaryImageFiles); + Directory.Delete(tempFolderName); + } + } + /// + /// Adds a poster image to an audio file. + /// + /// Source image file. + /// Source audio file. + /// Output video file. + /// + public static bool PosterWithAudio(string image, string audio, string output) + { + FFMpegHelper.ExtensionExceptionCheck(output, FileExtension.Mp4); + using (var img = Image.FromFile(image)) + FFMpegHelper.ConversionSizeExceptionCheck(img.Width, img.Height); + + return FFMpegArguments + .FromFileInput(image, false, options => options + .Loop(1) + .ForceFormat("image2")) + .AddFileInput(audio) + .OutputToFile(output, true, options => options + .ForcePixelFormat("yuv420p") + .WithVideoCodec(VideoCodec.LibX264) + .WithConstantRateFactor(21) + .WithAudioBitrate(AudioQuality.Normal) + .UsingShortest()) + .ProcessSynchronously(); + } + + /// + /// Saves a 'png' thumbnail to an in-memory bitmap + /// + /// Source video file. + /// Seek position where the thumbnail should be taken. + /// Thumbnail size. If width or height equal 0, the other will be computed automatically. + /// Selected video stream index. + /// Input file index + /// Bitmap with the requested snapshot. + public static Bitmap Snapshot(string input, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) + { + var source = FFProbe.Analyse(input); + var (arguments, outputOptions) = SnapshotArgumentBuilder.BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); + using var ms = new MemoryStream(); + + arguments + .OutputToPipe(new StreamPipeSink(ms), options => outputOptions(options + .ForceFormat("rawvideo"))) + .ProcessSynchronously(); + + ms.Position = 0; + using var bitmap = new Bitmap(ms); + return bitmap.Clone(new Rectangle(0, 0, bitmap.Width, bitmap.Height), bitmap.PixelFormat); + } + /// + /// Saves a 'png' thumbnail to an in-memory bitmap + /// + /// Source video file. + /// Seek position where the thumbnail should be taken. + /// Thumbnail size. If width or height equal 0, the other will be computed automatically. + /// Selected video stream index. + /// Input file index + /// Bitmap with the requested snapshot. + public static async Task SnapshotAsync(string input, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) + { + var source = await FFProbe.AnalyseAsync(input).ConfigureAwait(false); + var (arguments, outputOptions) = SnapshotArgumentBuilder.BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); + using var ms = new MemoryStream(); + + await arguments + .OutputToPipe(new StreamPipeSink(ms), options => outputOptions(options + .ForceFormat("rawvideo"))) + .ProcessAsynchronously(); + + ms.Position = 0; + return new Bitmap(ms); + } + private static void Cleanup(IEnumerable pathList) + { + foreach (var path in pathList) + { + if (File.Exists(path)) + File.Delete(path); + } + } + } +} \ No newline at end of file diff --git a/FFMpegCore/ImageInfo.cs b/FFMpegCore.Extensions.System.Drawing.Common/ImageInfo.cs similarity index 100% rename from FFMpegCore/ImageInfo.cs rename to FFMpegCore.Extensions.System.Drawing.Common/ImageInfo.cs diff --git a/FFMpegCore.Test/AudioTest.cs b/FFMpegCore.Test/AudioTest.cs index 795fedf..d20d21b 100644 --- a/FFMpegCore.Test/AudioTest.cs +++ b/FFMpegCore.Test/AudioTest.cs @@ -9,6 +9,8 @@ using System.IO; using System.Linq; using System.Threading.Tasks; +using FFMpegCore.Extensions.System.Drawing.Common; +using FFMpegCore.Test.Utilities; namespace FFMpegCore.Test { @@ -64,11 +66,11 @@ public void Audio_Add() Assert.IsTrue(File.Exists(outputFile)); } - [TestMethod] + [WindowsOnlyTestMethod] public void Image_AddAudio() { using var outputFile = new TemporaryFile("out.mp4"); - FFMpeg.PosterWithAudio(TestResources.PngImage, TestResources.Mp3Audio, outputFile); + FFMpegImage.PosterWithAudio(TestResources.PngImage, TestResources.Mp3Audio, outputFile); var analysis = FFProbe.Analyse(TestResources.Mp3Audio); Assert.IsTrue(analysis.Duration.TotalSeconds > 0); Assert.IsTrue(File.Exists(outputFile)); @@ -239,7 +241,7 @@ public void Audio_Pan_ToMono() Assert.IsTrue(success); Assert.AreEqual(1, mediaAnalysis.AudioStreams.Count); - Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream.ChannelLayout); + Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream!.ChannelLayout); } [TestMethod, Timeout(10000)] @@ -257,7 +259,7 @@ public void Audio_Pan_ToMonoNoDefinitions() Assert.IsTrue(success); Assert.AreEqual(1, mediaAnalysis.AudioStreams.Count); - Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream.ChannelLayout); + Assert.AreEqual("mono", mediaAnalysis.PrimaryAudioStream!.ChannelLayout); } [TestMethod, Timeout(10000)] diff --git a/FFMpegCore.Test/FFMpegArgumentProcessorTest.cs b/FFMpegCore.Test/FFMpegArgumentProcessorTest.cs index 6e30999..c849b81 100644 --- a/FFMpegCore.Test/FFMpegArgumentProcessorTest.cs +++ b/FFMpegCore.Test/FFMpegArgumentProcessorTest.cs @@ -13,7 +13,7 @@ public void TestInitialize() { // After testing reset global configuration to null, to be not wrong for other test relying on configuration - typeof(GlobalFFOptions).GetField("_current", BindingFlags.NonPublic | BindingFlags.Static).SetValue(GlobalFFOptions.Current, null); + typeof(GlobalFFOptions).GetField("_current", BindingFlags.NonPublic | BindingFlags.Static)!.SetValue(GlobalFFOptions.Current, null); } private static FFMpegArgumentProcessor CreateArgumentProcessor() => FFMpegArguments @@ -69,10 +69,6 @@ public void Processor_Options_CanBeOverridden_And_Configured() [TestMethod] public void Options_Global_And_Session_Options_Can_Differ() { - FFMpegArgumentProcessor CreateArgumentProcessor() => FFMpegArguments - .FromFileInput("") - .OutputToFile(""); - var globalWorkingDir = "Whatever"; GlobalFFOptions.Configure(new FFOptions { WorkingDirectory = globalWorkingDir }); diff --git a/FFMpegCore.Test/FFMpegCore.Test.csproj b/FFMpegCore.Test/FFMpegCore.Test.csproj index 4ac890c..5a0c11b 100644 --- a/FFMpegCore.Test/FFMpegCore.Test.csproj +++ b/FFMpegCore.Test/FFMpegCore.Test.csproj @@ -2,16 +2,31 @@ net6.0 - false - disable - default - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + PreserveNewest + PreserveNewest @@ -28,72 +43,44 @@ PreserveNewest - Always - - - - - PreserveNewest - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - - - - + - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest - Always + PreserveNewest PreserveNewest - - - - diff --git a/FFMpegCore.Test/MetaDataBuilderTests.cs b/FFMpegCore.Test/MetaDataBuilderTests.cs index 747fd9e..5209d4e 100644 --- a/FFMpegCore.Test/MetaDataBuilderTests.cs +++ b/FFMpegCore.Test/MetaDataBuilderTests.cs @@ -1,14 +1,7 @@ using FFMpegCore.Builders.MetaData; - using Microsoft.VisualStudio.TestTools.UnitTesting; - using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Text; using System.Text.RegularExpressions; -using System.Threading.Tasks; namespace FFMpegCore.Test { diff --git a/FFMpegCore.Test/Utilities/BitmapSources.cs b/FFMpegCore.Test/Utilities/BitmapSources.cs index 8ea02e8..044bfa8 100644 --- a/FFMpegCore.Test/Utilities/BitmapSources.cs +++ b/FFMpegCore.Test/Utilities/BitmapSources.cs @@ -1,13 +1,15 @@ -using FFMpegCore.Extend; -using System; +using System; using System.Collections.Generic; using System.Drawing; using System.Drawing.Imaging; using System.Numerics; +using System.Runtime.Versioning; +using FFMpegCore.Extensions.System.Drawing.Common; using FFMpegCore.Pipes; -namespace FFMpegCore.Test +namespace FFMpegCore.Test.Utilities { + [SupportedOSPlatform("windows")] static class BitmapSource { public static IEnumerable CreateBitmaps(int count, PixelFormat fmt, int w, int h) diff --git a/FFMpegCore.Test/Utilities/WindowsOnlyDataTestMethod.cs b/FFMpegCore.Test/Utilities/WindowsOnlyDataTestMethod.cs new file mode 100644 index 0000000..8c10054 --- /dev/null +++ b/FFMpegCore.Test/Utilities/WindowsOnlyDataTestMethod.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FFMpegCore.Test.Utilities; + +public class WindowsOnlyDataTestMethod : DataTestMethodAttribute +{ + public override TestResult[] Execute(ITestMethod testMethod) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var message = $"Test not executed on other platforms than Windows"; + { + return new[] + { + new TestResult { Outcome = UnitTestOutcome.Inconclusive, TestFailureException = new AssertInconclusiveException(message) } + }; + } + } + + return base.Execute(testMethod); + } +} \ No newline at end of file diff --git a/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs b/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs new file mode 100644 index 0000000..29300e3 --- /dev/null +++ b/FFMpegCore.Test/Utilities/WindowsOnlyTestMethod.cs @@ -0,0 +1,23 @@ +using System.Runtime.InteropServices; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FFMpegCore.Test.Utilities; + +public class WindowsOnlyTestMethod : TestMethodAttribute +{ + public override TestResult[] Execute(ITestMethod testMethod) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + var message = $"Test not executed on other platforms than Windows"; + { + return new[] + { + new TestResult { Outcome = UnitTestOutcome.Inconclusive, TestFailureException = new AssertInconclusiveException(message) } + }; + } + } + + return base.Execute(testMethod); + } +} \ No newline at end of file diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index 8f73575..46eac59 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -10,9 +10,12 @@ using System.Drawing.Imaging; using System.IO; using System.Linq; +using System.Runtime.Versioning; using System.Text; using System.Threading; using System.Threading.Tasks; +using FFMpegCore.Extensions.System.Drawing.Common; +using FFMpegCore.Test.Utilities; namespace FFMpegCore.Test { @@ -85,7 +88,8 @@ public void Video_ToH265_MKV_Args() Assert.IsTrue(success); } - [DataTestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyDataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] public void Video_ToMP4_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat) @@ -101,7 +105,8 @@ public void Video_ToMP4_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat Assert.IsTrue(success); } - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_ToMP4_Args_Pipe_DifferentImageSizes() { using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); @@ -120,8 +125,8 @@ public void Video_ToMP4_Args_Pipe_DifferentImageSizes() .ProcessSynchronously()); } - - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public async Task Video_ToMP4_Args_Pipe_DifferentImageSizes_Async() { using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); @@ -140,7 +145,8 @@ public async Task Video_ToMP4_Args_Pipe_DifferentImageSizes_Async() .ProcessAsynchronously()); } - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_ToMP4_Args_Pipe_DifferentPixelFormats() { using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); @@ -158,9 +164,9 @@ public void Video_ToMP4_Args_Pipe_DifferentPixelFormats() .WithVideoCodec(VideoCodec.LibX264)) .ProcessSynchronously()); } - - - [TestMethod, Timeout(10000)] + + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public async Task Video_ToMP4_Args_Pipe_DifferentPixelFormats_Async() { using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); @@ -314,7 +320,8 @@ public void Video_ToTS_Args() Assert.IsTrue(success); } - [DataTestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyDataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] public async Task Video_ToTS_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat) @@ -346,7 +353,8 @@ public async Task Video_ToOGV_Resize() Assert.IsTrue(success); } - [DataTestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyDataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] // [DataRow(PixelFormat.Format48bppRgb)] @@ -381,7 +389,8 @@ public void Scale_Mp4_Multithreaded() Assert.IsTrue(success); } - [DataTestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyDataTestMethod, Timeout(10000)] [DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)] [DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)] // [DataRow(PixelFormat.Format48bppRgb)] @@ -398,18 +407,20 @@ public void Video_ToMP4_Resize_Args_Pipe(System.Drawing.Imaging.PixelFormat pixe Assert.IsTrue(success); } - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_Snapshot_InMemory() { var input = FFProbe.Analyse(TestResources.Mp4Video); - using var bitmap = FFMpeg.Snapshot(TestResources.Mp4Video); - + using var bitmap = FFMpegImage.Snapshot(TestResources.Mp4Video); + Assert.AreEqual(input.PrimaryVideoStream!.Width, bitmap.Width); Assert.AreEqual(input.PrimaryVideoStream.Height, bitmap.Height); Assert.AreEqual(bitmap.RawFormat, ImageFormat.Png); } - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_Snapshot_PersistSnapshot() { var outputPath = new TemporaryFile("out.png"); @@ -445,7 +456,7 @@ public void Video_Join() Assert.AreEqual(input.PrimaryVideoStream.Width, result.PrimaryVideoStream.Width); } - [TestMethod, Timeout(10000)] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_Join_Image_Sequence() { var imageSet = new List(); @@ -460,8 +471,8 @@ public void Video_Join_Image_Sequence() } }); - using var outputFile = new TemporaryFile("out.mp4"); - var success = FFMpeg.JoinImageSequence(outputFile, images: imageSet.ToArray()); + var outputFile = new TemporaryFile("out.mp4"); + var success = FFMpegImage.JoinImageSequence(outputFile, images: imageSet.ToArray()); Assert.IsTrue(success); var result = FFProbe.Analyse(outputFile); Assert.AreEqual(3, result.Duration.Seconds); @@ -554,7 +565,8 @@ public void Video_OutputsData() Assert.IsTrue(File.Exists(outputFile)); } - [TestMethod, Timeout(10000)] + [SupportedOSPlatform("windows")] + [WindowsOnlyTestMethod, Timeout(10000)] public void Video_TranscodeInMemory() { using var resStream = new MemoryStream(); diff --git a/FFMpegCore.sln b/FFMpegCore.sln index 7a27980..5a9faa8 100644 --- a/FFMpegCore.sln +++ b/FFMpegCore.sln @@ -9,6 +9,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore.Test", "FFMpegCo EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore.Examples", "FFMpegCore.Examples\FFMpegCore.Examples.csproj", "{3125CF91-FFBD-4E4E-8930-247116AFE772}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FFMpegCore.Extensions.System.Drawing.Common", "FFMpegCore.Extensions.System.Drawing.Common\FFMpegCore.Extensions.System.Drawing.Common.csproj", "{9C1A4930-9369-4A18-AD98-929A2A510D80}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ Global {3125CF91-FFBD-4E4E-8930-247116AFE772}.Debug|Any CPU.Build.0 = Debug|Any CPU {3125CF91-FFBD-4E4E-8930-247116AFE772}.Release|Any CPU.ActiveCfg = Release|Any CPU {3125CF91-FFBD-4E4E-8930-247116AFE772}.Release|Any CPU.Build.0 = Release|Any CPU + {9C1A4930-9369-4A18-AD98-929A2A510D80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C1A4930-9369-4A18-AD98-929A2A510D80}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C1A4930-9369-4A18-AD98-929A2A510D80}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C1A4930-9369-4A18-AD98-929A2A510D80}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FFMpegCore/Extend/StringExtensions.cs b/FFMpegCore/Extend/StringExtensions.cs index 7b02089..29c8d42 100644 --- a/FFMpegCore/Extend/StringExtensions.cs +++ b/FFMpegCore/Extend/StringExtensions.cs @@ -1,5 +1,4 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Text; namespace FFMpegCore.Extend diff --git a/FFMpegCore/FFMpeg/Arguments/AudibleEncryptionKeyArgument.cs b/FFMpegCore/FFMpeg/Arguments/AudibleEncryptionKeyArgument.cs index 0f514dc..8b142e4 100644 --- a/FFMpegCore/FFMpeg/Arguments/AudibleEncryptionKeyArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/AudibleEncryptionKeyArgument.cs @@ -4,10 +4,10 @@ public class AudibleEncryptionKeyArgument : IArgument { private readonly bool _aaxcMode; - private readonly string _key; - private readonly string _iv; + private readonly string? _key; + private readonly string? _iv; - private readonly string _activationBytes; + private readonly string? _activationBytes; public AudibleEncryptionKeyArgument(string activationBytes) diff --git a/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs b/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs index 50b26b3..dac7d15 100644 --- a/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/AudioFiltersArgument.cs @@ -34,8 +34,8 @@ private string GetText() public interface IAudioFilterArgument { - public string Key { get; } - public string Value { get; } + string Key { get; } + string Value { get; } } public class AudioFilterOptions diff --git a/FFMpegCore/FFMpeg/Arguments/IDynamicArgument.cs b/FFMpegCore/FFMpeg/Arguments/IDynamicArgument.cs index 36a504e..20f8cdf 100644 --- a/FFMpegCore/FFMpeg/Arguments/IDynamicArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/IDynamicArgument.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Text; namespace FFMpegCore.Arguments { diff --git a/FFMpegCore/FFMpeg/Arguments/MapMetadataArgument.cs b/FFMpegCore/FFMpeg/Arguments/MapMetadataArgument.cs index afec731..7038139 100644 --- a/FFMpegCore/FFMpeg/Arguments/MapMetadataArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/MapMetadataArgument.cs @@ -1,9 +1,6 @@ -using FFMpegCore.Extend; - -using System; +using System; using System.Collections.Generic; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs b/FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs index 89bb1fe..5bcb7b1 100644 --- a/FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs @@ -1,10 +1,7 @@ -using FFMpegCore.Extend; - -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/FFMpegCore/FFMpeg/FFMpeg.cs b/FFMpegCore/FFMpeg/FFMpeg.cs index 9e9e0ce..909a96a 100644 --- a/FFMpegCore/FFMpeg/FFMpeg.cs +++ b/FFMpegCore/FFMpeg/FFMpeg.cs @@ -1,7 +1,6 @@ using FFMpegCore.Enums; using FFMpegCore.Exceptions; using FFMpegCore.Helpers; -using FFMpegCore.Pipes; using System; using System.Collections.Generic; using System.Drawing; @@ -12,102 +11,9 @@ namespace FFMpegCore { - public static class FFMpeg + public static class SnapshotArgumentBuilder { - /// - /// Saves a 'png' thumbnail from the input video to drive - /// - /// Source video analysis - /// Output video file path - /// Seek position where the thumbnail should be taken. - /// Thumbnail size. If width or height equal 0, the other will be computed automatically. - /// Selected video stream index. - /// Input file index - /// Bitmap with the requested snapshot. - public static bool Snapshot(string input, string output, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) - { - if (Path.GetExtension(output) != FileExtension.Png) - output = Path.GetFileNameWithoutExtension(output) + FileExtension.Png; - - var source = FFProbe.Analyse(input); - var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); - - return arguments - .OutputToFile(output, true, outputOptions) - .ProcessSynchronously(); - } - /// - /// Saves a 'png' thumbnail from the input video to drive - /// - /// Source video analysis - /// Output video file path - /// Seek position where the thumbnail should be taken. - /// Thumbnail size. If width or height equal 0, the other will be computed automatically. - /// Selected video stream index. - /// Input file index - /// Bitmap with the requested snapshot. - public static async Task SnapshotAsync(string input, string output, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) - { - if (Path.GetExtension(output) != FileExtension.Png) - output = Path.GetFileNameWithoutExtension(output) + FileExtension.Png; - - var source = await FFProbe.AnalyseAsync(input).ConfigureAwait(false); - var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); - - return await arguments - .OutputToFile(output, true, outputOptions) - .ProcessAsynchronously(); - } - - /// - /// Saves a 'png' thumbnail to an in-memory bitmap - /// - /// Source video file. - /// Seek position where the thumbnail should be taken. - /// Thumbnail size. If width or height equal 0, the other will be computed automatically. - /// Selected video stream index. - /// Input file index - /// Bitmap with the requested snapshot. - public static Bitmap Snapshot(string input, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) - { - var source = FFProbe.Analyse(input); - var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); - using var ms = new MemoryStream(); - - arguments - .OutputToPipe(new StreamPipeSink(ms), options => outputOptions(options - .ForceFormat("rawvideo"))) - .ProcessSynchronously(); - - ms.Position = 0; - using var bitmap = new Bitmap(ms); - return bitmap.Clone(new Rectangle(0, 0, bitmap.Width, bitmap.Height), bitmap.PixelFormat); - } - /// - /// Saves a 'png' thumbnail to an in-memory bitmap - /// - /// Source video file. - /// Seek position where the thumbnail should be taken. - /// Thumbnail size. If width or height equal 0, the other will be computed automatically. - /// Selected video stream index. - /// Input file index - /// Bitmap with the requested snapshot. - public static async Task SnapshotAsync(string input, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) - { - var source = await FFProbe.AnalyseAsync(input).ConfigureAwait(false); - var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); - using var ms = new MemoryStream(); - - await arguments - .OutputToPipe(new StreamPipeSink(ms), options => outputOptions(options - .ForceFormat("rawvideo"))) - .ProcessAsynchronously(); - - ms.Position = 0; - return new Bitmap(ms); - } - - private static (FFMpegArguments, Action outputOptions) BuildSnapshotArguments( + public static (FFMpegArguments, Action outputOptions) BuildSnapshotArguments( string input, IMediaAnalysis source, Size? size = null, @@ -157,13 +63,61 @@ private static (FFMpegArguments, Action outputOptions) Bu return null; } + } + public static class FFMpeg + { + /// + /// Saves a 'png' thumbnail from the input video to drive + /// + /// Source video analysis + /// Output video file path + /// Seek position where the thumbnail should be taken. + /// Thumbnail size. If width or height equal 0, the other will be computed automatically. + /// Selected video stream index. + /// Input file index + /// Bitmap with the requested snapshot. + public static bool Snapshot(string input, string output, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) + { + if (Path.GetExtension(output) != FileExtension.Png) + output = Path.GetFileNameWithoutExtension(output) + FileExtension.Png; + + var source = FFProbe.Analyse(input); + var (arguments, outputOptions) = SnapshotArgumentBuilder.BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); + + return arguments + .OutputToFile(output, true, outputOptions) + .ProcessSynchronously(); + } + /// + /// Saves a 'png' thumbnail from the input video to drive + /// + /// Source video analysis + /// Output video file path + /// Seek position where the thumbnail should be taken. + /// Thumbnail size. If width or height equal 0, the other will be computed automatically. + /// Selected video stream index. + /// Input file index + /// Bitmap with the requested snapshot. + public static async Task SnapshotAsync(string input, string output, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0) + { + if (Path.GetExtension(output) != FileExtension.Png) + output = Path.GetFileNameWithoutExtension(output) + FileExtension.Png; + + var source = await FFProbe.AnalyseAsync(input).ConfigureAwait(false); + var (arguments, outputOptions) = SnapshotArgumentBuilder.BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex); + + return await arguments + .OutputToFile(output, true, outputOptions) + .ProcessAsynchronously(); + } + /// /// Convert a video do a different format. /// - /// Input video source. + /// Input video source. /// Output information. - /// Target conversion video type. + /// Target conversion video format. /// Conversion target speed/quality (faster speed = lower quality). /// Video size. /// Conversion target audio quality. @@ -237,35 +191,6 @@ public static bool Convert( }; } - /// - /// Adds a poster image to an audio file. - /// - /// Source image file. - /// Source audio file. - /// Output video file. - /// - public static bool PosterWithAudio(string image, string audio, string output) - { - FFMpegHelper.ExtensionExceptionCheck(output, FileExtension.Mp4); - using (var imageFile = Image.FromFile(image)) - { - FFMpegHelper.ConversionSizeExceptionCheck(imageFile); - } - - return FFMpegArguments - .FromFileInput(image, false, options => options - .Loop(1) - .ForceFormat("image2")) - .AddFileInput(audio) - .OutputToFile(output, true, options => options - .ForcePixelFormat("yuv420p") - .WithVideoCodec(VideoCodec.LibX264) - .WithConstantRateFactor(21) - .WithAudioBitrate(AudioQuality.Normal) - .UsingShortest()) - .ProcessSynchronously(); - } - /// /// Joins a list of video files. /// @@ -299,44 +224,6 @@ public static bool Join(string output, params string[] videos) } } - /// - /// Converts an image sequence to a video. - /// - /// Output video file. - /// FPS - /// Image sequence collection - /// Output video information. - public static bool JoinImageSequence(string output, double frameRate = 30, params ImageInfo[] images) - { - var tempFolderName = Path.Combine(GlobalFFOptions.Current.TemporaryFilesFolder, Guid.NewGuid().ToString()); - var temporaryImageFiles = images.Select((imageInfo, index) => - { - using var image = Image.FromFile(imageInfo.FullName); - FFMpegHelper.ConversionSizeExceptionCheck(image); - var destinationPath = Path.Combine(tempFolderName, $"{index.ToString().PadLeft(9, '0')}{imageInfo.Extension}"); - Directory.CreateDirectory(tempFolderName); - File.Copy(imageInfo.FullName, destinationPath); - return destinationPath; - }).ToArray(); - - var firstImage = images.First(); - try - { - return FFMpegArguments - .FromFileInput(Path.Combine(tempFolderName, "%09d.png"), false) - .OutputToFile(output, true, options => options - .ForcePixelFormat("yuv420p") - .Resize(firstImage.Width, firstImage.Height) - .WithFramerate(frameRate)) - .ProcessSynchronously(); - } - finally - { - Cleanup(temporaryImageFiles); - Directory.Delete(tempFolderName); - } - } - /// /// Records M3U8 streams to the specified output. /// @@ -445,15 +332,15 @@ public static IReadOnlyList GetPixelFormats() return FFMpegCache.PixelFormats.Values.ToList().AsReadOnly(); } - public static bool TryGetPixelFormat(string name, out PixelFormat fmt) + public static bool TryGetPixelFormat(string name, out PixelFormat format) { if (!GlobalFFOptions.Current.UseCache) { - fmt = GetPixelFormatsInternal().FirstOrDefault(x => x.Name == name.ToLowerInvariant().Trim()); - return fmt != null; + format = GetPixelFormatsInternal().FirstOrDefault(x => x.Name == name.ToLowerInvariant().Trim()); + return format != null; } else - return FFMpegCache.PixelFormats.TryGetValue(name, out fmt); + return FFMpegCache.PixelFormats.TryGetValue(name, out format); } public static PixelFormat GetPixelFormat(string name) diff --git a/FFMpegCore/FFMpeg/FFMpegArguments.cs b/FFMpegCore/FFMpeg/FFMpegArguments.cs index c100c85..fad42ce 100644 --- a/FFMpegCore/FFMpeg/FFMpegArguments.cs +++ b/FFMpegCore/FFMpeg/FFMpegArguments.cs @@ -2,7 +2,6 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; diff --git a/FFMpegCore/FFMpegCore.csproj b/FFMpegCore/FFMpegCore.csproj index 79d191e..ecd0b85 100644 --- a/FFMpegCore/FFMpegCore.csproj +++ b/FFMpegCore/FFMpegCore.csproj @@ -1,45 +1,23 @@  - - en - https://github.com/rosenbjerg/FFMpegCore - https://github.com/rosenbjerg/FFMpegCore - - A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications - 4.0.0.0 - README.md - - Fixes for `MetaDataArgument` (thanks @JKamsker) -- Support for Audible `aaxc` (thanks @JKamsker) -- Include errordata in `IMediaAnalysis` (thanks @JKamsker) -- Pass `FFOptions` properly when using ffprobe (thanks @Notheisz57) -- CancellationToken support for `AnalyseAsync` -- Case-insensitive dictionaries for `Tags` and `Disposition` -- Fix for `PosterWithAudio` -- Fix for `JoinImageSequence` -- Updates to dependendies -- A lot of bug fixes - 8 - 4.8.0 - MIT - Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev - ffmpeg ffprobe convert video audio mediafile resize analyze muxing - GitHub - true - enable - netstandard2.0 - + + true + A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications + 5.0.0 + + + ffmpeg ffprobe convert video audio mediafile resize analyze muxing + Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev + README.md + - - - Always - - - + + + - - - - - + + + + diff --git a/FFMpegCore/FFProbe/FFProbeAnalysis.cs b/FFMpegCore/FFProbe/FFProbeAnalysis.cs index cbbb9fd..b81d535 100644 --- a/FFMpegCore/FFProbe/FFProbeAnalysis.cs +++ b/FFMpegCore/FFProbe/FFProbeAnalysis.cs @@ -12,7 +12,7 @@ public class FFProbeAnalysis public Format Format { get; set; } = null!; [JsonIgnore] - public IReadOnlyList ErrorData { get; set; } + public IReadOnlyList ErrorData { get; set; } = new List(); } public class FFProbeStream : ITagsContainer, IDispositionContainer @@ -108,7 +108,7 @@ public class Format : ITagsContainer public string Size { get; set; } = null!; [JsonPropertyName("bit_rate")] - public string BitRate { get; set; } = null!; + public string? BitRate { get; set; } = null!; [JsonPropertyName("probe_score")] public int ProbeScore { get; set; } diff --git a/FFMpegCore/FFProbe/MediaAnalysis.cs b/FFMpegCore/FFProbe/MediaAnalysis.cs index e1fbd1d..fd2b135 100644 --- a/FFMpegCore/FFProbe/MediaAnalysis.cs +++ b/FFMpegCore/FFProbe/MediaAnalysis.cs @@ -13,7 +13,7 @@ internal MediaAnalysis(FFProbeAnalysis analysis) VideoStreams = analysis.Streams.Where(stream => stream.CodecType == "video").Select(ParseVideoStream).ToList(); AudioStreams = analysis.Streams.Where(stream => stream.CodecType == "audio").Select(ParseAudioStream).ToList(); SubtitleStreams = analysis.Streams.Where(stream => stream.CodecType == "subtitle").Select(ParseSubtitleStream).ToList(); - ErrorData = analysis.ErrorData ?? new List().AsReadOnly(); + ErrorData = analysis.ErrorData; } private MediaFormat ParseFormat(Format analysisFormat) diff --git a/FFMpegCore/Helpers/FFMpegHelper.cs b/FFMpegCore/Helpers/FFMpegHelper.cs index cb3b4cf..97def0f 100644 --- a/FFMpegCore/Helpers/FFMpegHelper.cs +++ b/FFMpegCore/Helpers/FFMpegHelper.cs @@ -1,5 +1,4 @@ using System; -using System.Drawing; using System.IO; using FFMpegCore.Exceptions; using Instances; @@ -10,13 +9,10 @@ public static class FFMpegHelper { private static bool _ffmpegVerified; - public static void ConversionSizeExceptionCheck(Image image) - => ConversionSizeExceptionCheck(image.Size.Width, image.Size.Height); - public static void ConversionSizeExceptionCheck(IMediaAnalysis info) => ConversionSizeExceptionCheck(info.PrimaryVideoStream!.Width, info.PrimaryVideoStream.Height); - private static void ConversionSizeExceptionCheck(int width, int height) + public static void ConversionSizeExceptionCheck(int width, int height) { if (height % 2 != 0 || width % 2 != 0 ) throw new ArgumentException("FFMpeg yuv420p encoding requires the width and height to be a multiple of 2!");