diff --git a/FFMpegCore.Examples/FFMpegCore.Examples.csproj b/FFMpegCore.Examples/FFMpegCore.Examples.csproj new file mode 100644 index 0000000..f9daae7 --- /dev/null +++ b/FFMpegCore.Examples/FFMpegCore.Examples.csproj @@ -0,0 +1,12 @@ + + + + Exe + net5.0 + + + + + + + diff --git a/FFMpegCore.Examples/Program.cs b/FFMpegCore.Examples/Program.cs new file mode 100644 index 0000000..256ef3c --- /dev/null +++ b/FFMpegCore.Examples/Program.cs @@ -0,0 +1,124 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.IO; +using FFMpegCore; +using FFMpegCore.Enums; +using FFMpegCore.Pipes; +using FFMpegCore.Extend; + +var inputPath = "/path/to/input"; +var outputPath = "/path/to/output"; + +{ + var mediaInfo = FFProbe.Analyse(inputPath); +} + +{ + var mediaInfo = await FFProbe.AnalyseAsync(inputPath); +} + +{ + FFMpegArguments + .FromFileInput(inputPath) + .OutputToFile(outputPath, false, options => options + .WithVideoCodec(VideoCodec.LibX264) + .WithConstantRateFactor(21) + .WithAudioCodec(AudioCodec.Aac) + .WithVariableBitrate(4) + .WithVideoFilters(filterOptions => filterOptions + .Scale(VideoSize.Hd)) + .WithFastStart()) + .ProcessSynchronously(); +} + +{ + // process the snapshot in-memory and use the Bitmap directly + var bitmap = FFMpeg.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)); +} + +var inputStream = new MemoryStream(); +var outputStream = new MemoryStream(); + +{ + await FFMpegArguments + .FromPipeInput(new StreamPipeSource(inputStream)) + .OutputToPipe(new StreamPipeSink(outputStream), options => options + .WithVideoCodec("vp9") + .ForceFormat("webm")) + .ProcessAsynchronously(); +} + +{ + FFMpeg.Join(@"..\joined_video.mp4", + @"..\part1.mp4", + @"..\part2.mp4", + @"..\part3.mp4" + ); +} + +{ + FFMpeg.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1, + ImageInfo.FromPath(@"..\1.png"), + ImageInfo.FromPath(@"..\2.png"), + ImageInfo.FromPath(@"..\3.png") + ); +} + +{ + FFMpeg.Mute(inputPath, outputPath); +} + +{ + FFMpeg.ExtractAudio(inputPath, outputPath); +} + +var inputAudioPath = "/path/to/input/audio"; +{ + FFMpeg.ReplaceAudio(inputPath, inputAudioPath, outputPath); +} + +var inputImagePath = "/path/to/input/image"; +{ + FFMpeg.PosterWithAudio(inputPath, inputAudioPath, outputPath); + // or + var image = Image.FromFile(inputImagePath); + image.AddAudio(inputAudioPath, outputPath); +} + +IVideoFrame GetNextFrame() => throw new NotImplementedException(); +{ + IEnumerable CreateFrames(int count) + { + for(int i = 0; i < count; i++) + { + yield return GetNextFrame(); //method of generating new frames + } + } + + var videoFramesSource = new RawVideoPipeSource(CreateFrames(64)) //pass IEnumerable or IEnumerator to constructor of RawVideoPipeSource + { + FrameRate = 30 //set source frame rate + }; + await FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputPath, false, options => options + .WithVideoCodec(VideoCodec.LibVpx)) + .ProcessAsynchronously(); +} + +{ + // setting global options + GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" }); + // or + GlobalFFOptions.Configure(options => options.BinaryFolder = "./bin"); + + // or individual, per-run options + await FFMpegArguments + .FromFileInput(inputPath) + .OutputToFile(outputPath) + .ProcessAsynchronously(true, new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" }); +} \ No newline at end of file diff --git a/FFMpegCore.Test/ArgumentBuilderTest.cs b/FFMpegCore.Test/ArgumentBuilderTest.cs index f0792ef..18a8c7d 100644 --- a/FFMpegCore.Test/ArgumentBuilderTest.cs +++ b/FFMpegCore.Test/ArgumentBuilderTest.cs @@ -239,9 +239,8 @@ public void Builder_BuildString_Loop() [TestMethod] public void Builder_BuildString_Seek() { - var str = FFMpegArguments.FromFileInput("input.mp4", false, opt => opt.Seek(TimeSpan.FromSeconds(10))) - .OutputToFile("output.mp4", false, opt => opt.Seek(TimeSpan.FromSeconds(10))).Arguments; - Assert.AreEqual("-ss 00:00:10 -i \"input.mp4\" -ss 00:00:10 \"output.mp4\"", str); + var str = FFMpegArguments.FromFileInput("input.mp4", false, opt => opt.Seek(TimeSpan.FromSeconds(10))).OutputToFile("output.mp4", false, opt => opt.Seek(TimeSpan.FromSeconds(10))).Arguments; + Assert.AreEqual("-ss 00:00:10.000 -i \"input.mp4\" -ss 00:00:10.000 \"output.mp4\"", str); } [TestMethod] diff --git a/FFMpegCore.Test/FFMpegCore.Test.csproj b/FFMpegCore.Test/FFMpegCore.Test.csproj index 2cd97bb..98c9274 100644 --- a/FFMpegCore.Test/FFMpegCore.Test.csproj +++ b/FFMpegCore.Test/FFMpegCore.Test.csproj @@ -39,10 +39,10 @@ - - - - + + + + diff --git a/FFMpegCore.Test/FFProbeTests.cs b/FFMpegCore.Test/FFProbeTests.cs index 5ac83a1..5cabc4e 100644 --- a/FFMpegCore.Test/FFProbeTests.cs +++ b/FFMpegCore.Test/FFProbeTests.cs @@ -25,6 +25,24 @@ public async Task Audio_FromStream_Duration() Assert.IsTrue(fileAnalysis.Duration == streamAnalysis.Duration); } + [DataTestMethod] + [DataRow("0:00:03.008000", 0, 0, 0, 3, 8)] + [DataRow("05:12:59.177", 0, 5, 12, 59, 177)] + [DataRow("149:07:50.911750", 6, 5, 7, 50, 911)] + [DataRow("00:00:00.83", 0, 0, 0, 0, 830)] + public void MediaAnalysis_ParseDuration(string duration, int expectedDays, int expectedHours, int expectedMinutes, int expectedSeconds, int expectedMilliseconds) + { + var ffprobeStream = new FFProbeStream { Duration = duration }; + + var parsedDuration = MediaAnalysisUtils.ParseDuration(ffprobeStream); + + Assert.AreEqual(expectedDays, parsedDuration.Days); + Assert.AreEqual(expectedHours, parsedDuration.Hours); + Assert.AreEqual(expectedMinutes, parsedDuration.Minutes); + Assert.AreEqual(expectedSeconds, parsedDuration.Seconds); + Assert.AreEqual(expectedMilliseconds, parsedDuration.Milliseconds); + } + [TestMethod] public async Task Uri_Duration() { diff --git a/FFMpegCore.Test/Utilities/BitmapSources.cs b/FFMpegCore.Test/Utilities/BitmapSources.cs index c3e8d40..8ea02e8 100644 --- a/FFMpegCore.Test/Utilities/BitmapSources.cs +++ b/FFMpegCore.Test/Utilities/BitmapSources.cs @@ -21,7 +21,7 @@ public static IEnumerable CreateBitmaps(int count, PixelFormat fmt, } } - private static BitmapVideoFrameWrapper CreateVideoFrame(int index, PixelFormat fmt, int w, int h, float scaleNoise, float offset) + public static BitmapVideoFrameWrapper CreateVideoFrame(int index, PixelFormat fmt, int w, int h, float scaleNoise, float offset) { var bitmap = new Bitmap(w, h, fmt); diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index 30e6a9a..149dabd 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -87,6 +87,92 @@ public void Video_ToMP4_Args_Pipe(System.Drawing.Imaging.PixelFormat pixelFormat Assert.IsTrue(success); } + [TestMethod, Timeout(10000)] + public void Video_ToMP4_Args_Pipe_DifferentImageSizes() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var frames = new List + { + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 255, 255, 1, 0), + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 256, 256, 1, 0) + }; + + var videoFramesSource = new RawVideoPipeSource(frames); + var ex = Assert.ThrowsException(() => FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputFile, false, opt => opt + .WithVideoCodec(VideoCodec.LibX264)) + .ProcessSynchronously()); + + Assert.IsInstanceOfType(ex.GetBaseException(), typeof(FFMpegStreamFormatException)); + } + + + [TestMethod, Timeout(10000)] + public async Task Video_ToMP4_Args_Pipe_DifferentImageSizes_Async() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var frames = new List + { + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 255, 255, 1, 0), + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 256, 256, 1, 0) + }; + + var videoFramesSource = new RawVideoPipeSource(frames); + var ex = await Assert.ThrowsExceptionAsync(() => FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputFile, false, opt => opt + .WithVideoCodec(VideoCodec.LibX264)) + .ProcessAsynchronously()); + + Assert.IsInstanceOfType(ex.GetBaseException(), typeof(FFMpegStreamFormatException)); + } + + [TestMethod, Timeout(10000)] + public void Video_ToMP4_Args_Pipe_DifferentPixelFormats() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var frames = new List + { + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 255, 255, 1, 0), + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format32bppRgb, 255, 255, 1, 0) + }; + + var videoFramesSource = new RawVideoPipeSource(frames); + var ex = Assert.ThrowsException(() => FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputFile, false, opt => opt + .WithVideoCodec(VideoCodec.LibX264)) + .ProcessSynchronously()); + + Assert.IsInstanceOfType(ex.GetBaseException(), typeof(FFMpegStreamFormatException)); + } + + + [TestMethod, Timeout(10000)] + public async Task Video_ToMP4_Args_Pipe_DifferentPixelFormats_Async() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + var frames = new List + { + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format24bppRgb, 255, 255, 1, 0), + BitmapSource.CreateVideoFrame(0, System.Drawing.Imaging.PixelFormat.Format32bppRgb, 255, 255, 1, 0) + }; + + var videoFramesSource = new RawVideoPipeSource(frames); + var ex = await Assert.ThrowsExceptionAsync(() => FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputFile, false, opt => opt + .WithVideoCodec(VideoCodec.LibX264)) + .ProcessAsynchronously()); + + Assert.IsInstanceOfType(ex.GetBaseException(), typeof(FFMpegStreamFormatException)); + } + [TestMethod, Timeout(10000)] public void Video_ToMP4_Args_StreamPipe() { @@ -114,6 +200,8 @@ await FFMpegArguments .ProcessAsynchronously(); }); } + + [TestMethod, Timeout(10000)] public void Video_StreamFile_OutputToMemoryStream() { diff --git a/FFMpegCore.sln b/FFMpegCore.sln index eab20fd..27eab0a 100644 --- a/FFMpegCore.sln +++ b/FFMpegCore.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FFMpegCore", "FFMpegCore\FF EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FFMpegCore.Test", "FFMpegCore.Test\FFMpegCore.Test.csproj", "{F20C8353-72D9-454B-9F16-3624DBAD2328}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FFMpegCore.Examples", "FFMpegCore.Examples\FFMpegCore.Examples.csproj", "{3125CF91-FFBD-4E4E-8930-247116AFE772}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {F20C8353-72D9-454B-9F16-3624DBAD2328}.Debug|Any CPU.Build.0 = Debug|Any CPU {F20C8353-72D9-454B-9F16-3624DBAD2328}.Release|Any CPU.ActiveCfg = Release|Any CPU {F20C8353-72D9-454B-9F16-3624DBAD2328}.Release|Any CPU.Build.0 = Release|Any CPU + {3125CF91-FFBD-4E4E-8930-247116AFE772}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/FFMpegCore/Assembly.cs b/FFMpegCore/Assembly.cs new file mode 100644 index 0000000..0117671 --- /dev/null +++ b/FFMpegCore/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("FFMpegCore.Test")] \ No newline at end of file diff --git a/FFMpegCore/Extend/BitmapExtensions.cs b/FFMpegCore/Extend/BitmapExtensions.cs index bf10336..e2f5505 100644 --- a/FFMpegCore/Extend/BitmapExtensions.cs +++ b/FFMpegCore/Extend/BitmapExtensions.cs @@ -6,7 +6,7 @@ namespace FFMpegCore.Extend { public static class BitmapExtensions { - public static bool AddAudio(this Bitmap poster, string audio, string output) + public static bool AddAudio(this Image poster, string audio, string output) { var destination = $"{Environment.TickCount}.png"; poster.Save(destination); diff --git a/FFMpegCore/FFMpeg/Arguments/OutputUrlArgument.cs b/FFMpegCore/FFMpeg/Arguments/OutputUrlArgument.cs new file mode 100644 index 0000000..15cbef9 --- /dev/null +++ b/FFMpegCore/FFMpeg/Arguments/OutputUrlArgument.cs @@ -0,0 +1,27 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace FFMpegCore.Arguments +{ + /// + /// Represents outputting to url using supported protocols + /// See http://ffmpeg.org/ffmpeg-protocols.html + /// + public class OutputUrlArgument : IOutputArgument + { + public readonly string Url; + + public OutputUrlArgument(string url) + { + Url = url; + } + + public void Post() { } + + public Task During(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Pre() { } + + public string Text => Url; + } +} diff --git a/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs b/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs index 428f21b..fcb944a 100644 --- a/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/PipeArgument.cs @@ -40,14 +40,17 @@ public async Task During(CancellationToken cancellationToken = default) { try { - await ProcessDataAsync(cancellationToken); - Debug.WriteLine($"Disconnecting NamedPipeServerStream on {GetType().Name}"); - Pipe?.Disconnect(); + await ProcessDataAsync(cancellationToken); } catch (TaskCanceledException) { Debug.WriteLine($"ProcessDataAsync on {GetType().Name} cancelled"); } + finally + { + Debug.WriteLine($"Disconnecting NamedPipeServerStream on {GetType().Name}"); + Pipe?.Disconnect(); + } } protected abstract Task ProcessDataAsync(CancellationToken token); diff --git a/FFMpegCore/FFMpeg/Arguments/SeekArgument.cs b/FFMpegCore/FFMpeg/Arguments/SeekArgument.cs index 1057b88..1b58890 100644 --- a/FFMpegCore/FFMpeg/Arguments/SeekArgument.cs +++ b/FFMpegCore/FFMpeg/Arguments/SeekArgument.cs @@ -8,11 +8,28 @@ namespace FFMpegCore.Arguments public class SeekArgument : IArgument { public readonly TimeSpan? SeekTo; + public SeekArgument(TimeSpan? seekTo) { SeekTo = seekTo; } - - public string Text => !SeekTo.HasValue ? string.Empty : $"-ss {SeekTo.Value}"; + + public string Text { + get { + if(SeekTo.HasValue) + { + int hours = SeekTo.Value.Hours; + if(SeekTo.Value.Days > 0) + { + hours += SeekTo.Value.Days * 24; + } + return $"-ss {hours.ToString("00")}:{SeekTo.Value.Minutes.ToString("00")}:{SeekTo.Value.Seconds.ToString("00")}.{SeekTo.Value.Milliseconds.ToString("000")}"; + } + else + { + return string.Empty; + } + } + } } } diff --git a/FFMpegCore/FFMpeg/Exceptions/FFMpegException.cs b/FFMpegCore/FFMpeg/Exceptions/FFMpegException.cs index dad6ef1..485cf20 100644 --- a/FFMpegCore/FFMpeg/Exceptions/FFMpegException.cs +++ b/FFMpegCore/FFMpeg/Exceptions/FFMpegException.cs @@ -49,4 +49,12 @@ public FFMpegArgumentException(string? message = null, Exception? innerException { } } + + public class FFMpegStreamFormatException : FFMpegException + { + public FFMpegStreamFormatException(FFMpegExceptionType type, string message, Exception? innerException = null) + : base(type, message, innerException) + { + } + } } \ No newline at end of file diff --git a/FFMpegCore/FFMpeg/FFMpeg.cs b/FFMpegCore/FFMpeg/FFMpeg.cs index 3f3d162..42c344b 100644 --- a/FFMpegCore/FFMpeg/FFMpeg.cs +++ b/FFMpegCore/FFMpeg/FFMpeg.cs @@ -163,8 +163,8 @@ public static bool Convert( var source = FFProbe.Analyse(input); FFMpegHelper.ConversionSizeExceptionCheck(source); - var scale = VideoSize.Original == size ? 1 : (double)source.PrimaryVideoStream.Height / (int)size; - var outputSize = new Size((int)(source.PrimaryVideoStream.Width / scale), (int)(source.PrimaryVideoStream.Height / scale)); + var scale = VideoSize.Original == size ? 1 : (double)source.PrimaryVideoStream!.Height / (int)size; + var outputSize = new Size((int)(source.PrimaryVideoStream!.Width / scale), (int)(source.PrimaryVideoStream.Height / scale)); if (outputSize.Width % 2 != 0) outputSize.Width += 1; diff --git a/FFMpegCore/FFMpeg/FFMpegArguments.cs b/FFMpegCore/FFMpeg/FFMpegArguments.cs index 57ff68c..847e68c 100644 --- a/FFMpegCore/FFMpeg/FFMpegArguments.cs +++ b/FFMpegCore/FFMpeg/FFMpegArguments.cs @@ -49,7 +49,8 @@ private FFMpegArguments WithInput(IInputArgument inputArgument, Action? addArguments = null) => ToProcessor(new OutputArgument(file, overwrite), addArguments); - public FFMpegArgumentProcessor OutputToFile(Uri uri, bool overwrite = true, Action? addArguments = null) => ToProcessor(new OutputArgument(uri.AbsolutePath, overwrite), addArguments); + public FFMpegArgumentProcessor OutputToUrl(string uri, Action? addArguments = null) => ToProcessor(new OutputUrlArgument(uri), addArguments); + public FFMpegArgumentProcessor OutputToUrl(Uri uri, Action? addArguments = null) => ToProcessor(new OutputUrlArgument(uri.ToString()), addArguments); public FFMpegArgumentProcessor OutputToPipe(IPipeSink reader, Action? addArguments = null) => ToProcessor(new OutputPipeArgument(reader), addArguments); private FFMpegArgumentProcessor ToProcessor(IOutputArgument argument, Action? addArguments) diff --git a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs index 378cead..65f622e 100644 --- a/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs +++ b/FFMpegCore/FFMpeg/Pipes/RawVideoPipeSource.cs @@ -64,7 +64,7 @@ public async Task WriteAsync(System.IO.Stream outputStream, CancellationToken ca private void CheckFrameAndThrow(IVideoFrame frame) { if (frame.Width != Width || frame.Height != Height || frame.Format != StreamFormat) - throw new FFMpegException(FFMpegExceptionType.Operation, "Video frame is not the same format as created raw video stream\r\n" + + throw new FFMpegStreamFormatException(FFMpegExceptionType.Operation, "Video frame is not the same format as created raw video stream\r\n" + $"Frame format: {frame.Width}x{frame.Height} pix_fmt: {frame.Format}\r\n" + $"Stream format: {Width}x{Height} pix_fmt: {StreamFormat}"); } diff --git a/FFMpegCore/FFMpegCore.csproj b/FFMpegCore/FFMpegCore.csproj index 69a8e61..47b41d8 100644 --- a/FFMpegCore/FFMpegCore.csproj +++ b/FFMpegCore/FFMpegCore.csproj @@ -5,18 +5,17 @@ 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 C# applications + A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications 3.0.0.0 3.0.0.0 3.0.0.0 - - Video filter args refactored to support multiple arguments -- Cancel improved with timeout (thanks TFleury) -- Basic support for webcam/mic input through InputDeviceArgument (thanks TFleury) -- Other fixes and improvements + - Fixes for RawVideoPipeSource hanging (thanks to max619) +- Added .OutputToUrl(..) method for outputting to url using supported protocol (thanks to TFleury) +- Improved timespan parsing (thanks to test-in-prod) 8 - 4.0.0 + 4.1.0 MIT - Malte Rosenbjerg, Vlad Jerca + Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev ffmpeg ffprobe convert video audio mediafile resize analyze muxing GitHub true @@ -32,7 +31,7 @@ - + diff --git a/FFMpegCore/FFProbe/MediaAnalysis.cs b/FFMpegCore/FFProbe/MediaAnalysis.cs index f1b2f82..2602f86 100644 --- a/FFMpegCore/FFProbe/MediaAnalysis.cs +++ b/FFMpegCore/FFProbe/MediaAnalysis.cs @@ -7,8 +7,6 @@ namespace FFMpegCore { internal class MediaAnalysis : IMediaAnalysis { - private static readonly Regex DurationRegex = new Regex("^(\\d{1,2}:\\d{1,2}:\\d{1,2}(.\\d{1,7})?)", RegexOptions.Compiled); - internal MediaAnalysis(FFProbeAnalysis analysis) { Format = ParseFormat(analysis.Format); @@ -20,7 +18,7 @@ private MediaFormat ParseFormat(Format analysisFormat) { return new MediaFormat { - Duration = TimeSpan.Parse(analysisFormat.Duration ?? "0"), + Duration = MediaAnalysisUtils.ParseDuration(analysisFormat.Duration), FormatName = analysisFormat.FormatName, FormatLongName = analysisFormat.FormatLongName, StreamCount = analysisFormat.NbStreams, @@ -50,14 +48,14 @@ private VideoStream ParseVideoStream(FFProbeStream stream) return new VideoStream { Index = stream.Index, - AvgFrameRate = DivideRatio(ParseRatioDouble(stream.AvgFrameRate, '/')), - BitRate = !string.IsNullOrEmpty(stream.BitRate) ? ParseIntInvariant(stream.BitRate) : default, - BitsPerRawSample = !string.IsNullOrEmpty(stream.BitsPerRawSample) ? ParseIntInvariant(stream.BitsPerRawSample) : default, + AvgFrameRate = MediaAnalysisUtils.DivideRatio(MediaAnalysisUtils.ParseRatioDouble(stream.AvgFrameRate, '/')), + BitRate = !string.IsNullOrEmpty(stream.BitRate) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitRate) : default, + BitsPerRawSample = !string.IsNullOrEmpty(stream.BitsPerRawSample) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitsPerRawSample) : default, CodecName = stream.CodecName, CodecLongName = stream.CodecLongName, - DisplayAspectRatio = ParseRatioInt(stream.DisplayAspectRatio, ':'), - Duration = ParseDuration(stream), - FrameRate = DivideRatio(ParseRatioDouble(stream.FrameRate, '/')), + DisplayAspectRatio = MediaAnalysisUtils.ParseRatioInt(stream.DisplayAspectRatio, ':'), + Duration = MediaAnalysisUtils.ParseDuration(stream), + FrameRate = MediaAnalysisUtils.DivideRatio(MediaAnalysisUtils.ParseRatioDouble(stream.FrameRate, '/')), Height = stream.Height ?? 0, Width = stream.Width ?? 0, Profile = stream.Profile, @@ -68,57 +66,89 @@ private VideoStream ParseVideoStream(FFProbeStream stream) }; } - private static TimeSpan ParseDuration(FFProbeStream ffProbeStream) - { - return !string.IsNullOrEmpty(ffProbeStream.Duration) - ? TimeSpan.Parse(ffProbeStream.Duration) - : TimeSpan.Parse(TrimTimeSpan(ffProbeStream.GetDuration()) ?? "0"); - } - - private static string? TrimTimeSpan(string? durationTag) - { - var durationMatch = DurationRegex.Match(durationTag ?? ""); - return durationMatch.Success ? durationMatch.Groups[1].Value : null; - } - private AudioStream ParseAudioStream(FFProbeStream stream) { return new AudioStream { Index = stream.Index, - BitRate = !string.IsNullOrEmpty(stream.BitRate) ? ParseIntInvariant(stream.BitRate) : default, + BitRate = !string.IsNullOrEmpty(stream.BitRate) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitRate) : default, CodecName = stream.CodecName, CodecLongName = stream.CodecLongName, Channels = stream.Channels ?? default, ChannelLayout = stream.ChannelLayout, - Duration = ParseDuration(stream), - SampleRateHz = !string.IsNullOrEmpty(stream.SampleRate) ? ParseIntInvariant(stream.SampleRate) : default, + Duration = MediaAnalysisUtils.ParseDuration(stream), + SampleRateHz = !string.IsNullOrEmpty(stream.SampleRate) ? MediaAnalysisUtils.ParseIntInvariant(stream.SampleRate) : default, Profile = stream.Profile, Language = stream.GetLanguage(), Tags = stream.Tags, }; } - private static double DivideRatio((double, double) ratio) => ratio.Item1 / ratio.Item2; - private static (int, int) ParseRatioInt(string input, char separator) + } + + public static class MediaAnalysisUtils + { + private static readonly Regex DurationRegex = new Regex(@"^(\d+):(\d{1,2}):(\d{1,2})\.(\d{1,3})", RegexOptions.Compiled); + + public static double DivideRatio((double, double) ratio) => ratio.Item1 / ratio.Item2; + + public static (int, int) ParseRatioInt(string input, char separator) { if (string.IsNullOrEmpty(input)) return (0, 0); var ratio = input.Split(separator); return (ParseIntInvariant(ratio[0]), ParseIntInvariant(ratio[1])); } - private static (double, double) ParseRatioDouble(string input, char separator) + public static (double, double) ParseRatioDouble(string input, char separator) { if (string.IsNullOrEmpty(input)) return (0, 0); var ratio = input.Split(separator); return (ratio.Length > 0 ? ParseDoubleInvariant(ratio[0]) : 0, ratio.Length > 1 ? ParseDoubleInvariant(ratio[1]) : 0); } - private static double ParseDoubleInvariant(string line) => + public static double ParseDoubleInvariant(string line) => double.Parse(line, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture); - private static int ParseIntInvariant(string line) => + public static int ParseIntInvariant(string line) => int.Parse(line, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture); + + + public static TimeSpan ParseDuration(string duration) + { + if (!string.IsNullOrEmpty(duration)) + { + var match = DurationRegex.Match(duration); + if (match.Success) + { + // ffmpeg may provide < 3-digit number of milliseconds (omitting trailing zeros), which won't simply parse correctly + // e.g. 00:12:02.11 -> 12 minutes 2 seconds and 110 milliseconds + var millisecondsPart = match.Groups[4].Value; + if (millisecondsPart.Length < 3) + { + millisecondsPart = millisecondsPart.PadRight(3, '0'); + } + + var hours = int.Parse(match.Groups[1].Value); + var minutes = int.Parse(match.Groups[2].Value); + var seconds = int.Parse(match.Groups[3].Value); + var milliseconds = int.Parse(millisecondsPart); + return new TimeSpan(0, hours, minutes, seconds, milliseconds); + } + else + { + return TimeSpan.Zero; + } + } + else + { + return TimeSpan.Zero; + } + } + + public static TimeSpan ParseDuration(FFProbeStream ffProbeStream) + { + return ParseDuration(ffProbeStream.Duration); + } } } \ No newline at end of file diff --git a/README.md b/README.md index 6a9fc35..9dce345 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and FFProbe is used to gather media information: ```csharp -var mediaInfo = FFProbe.Analyse(inputFile); +var mediaInfo = FFProbe.Analyse(inputPath); ``` or ```csharp -var mediaInfo = await FFProbe.AnalyseAsync(inputFile); +var mediaInfo = await FFProbe.AnalyseAsync(inputPath); ``` @@ -43,20 +43,19 @@ FFMpegArguments .WithConstantRateFactor(21) .WithAudioCodec(AudioCodec.Aac) .WithVariableBitrate(4) - .WithFastStart() - .Scale(VideoSize.Hd)) + .WithVideoFilters(filterOptions => filterOptions + .Scale(VideoSize.Hd)) + .WithFastStart()) .ProcessSynchronously(); ``` Easily capture screens from your videos: ```csharp -var mediaFileAnalysis = FFProbe.Analyse(inputPath); - // process the snapshot in-memory and use the Bitmap directly -var bitmap = FFMpeg.Snapshot(mediaFileAnalysis, new Size(200, 400), TimeSpan.FromMinutes(1)); +var bitmap = FFMpeg.Snapshot(inputPath, new Size(200, 400), TimeSpan.FromMinutes(1)); // or persists the image on the drive -FFMpeg.Snapshot(mediaFileAnalysis, outputPath, new Size(200, 400), TimeSpan.FromMinutes(1)) +FFMpeg.Snapshot(inputPath, outputPath, new Size(200, 400), TimeSpan.FromMinutes(1)); ``` Convert to and/or from streams @@ -89,25 +88,25 @@ FFMpeg.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1, Mute videos: ```csharp -FFMpeg.Mute(inputFilePath, outputFilePath); +FFMpeg.Mute(inputPath, outputPath); ``` Save audio track from video: ```csharp -FFMpeg.ExtractAudio(inputVideoFilePath, outputAudioFilePath); +FFMpeg.ExtractAudio(inputPath, outputPath); ``` Add or replace audio track on video: ```csharp -FFMpeg.ReplaceAudio(inputVideoFilePath, inputAudioFilePath, outputVideoFilePath); +FFMpeg.ReplaceAudio(inputPath, inputAudioPath, outputPath); ``` Add poster image to audio file (good for youtube videos): ```csharp -FFMpeg.PosterWithAudio(inputImageFilePath, inputAudioFilePath, outputVideoFilePath); +FFMpeg.PosterWithAudio(inputPath, inputAudioPath, outputPath); // or -var image = Image.FromFile(inputImageFile); -image.AddAudio(inputAudioFilePath, outputVideoFilePath); +var image = Image.FromFile(inputImagePath); +image.AddAudio(inputAudioPath, outputPath); ``` Other available arguments could be found in `FFMpegCore.Arguments` namespace. @@ -135,10 +134,11 @@ var videoFramesSource = new RawVideoPipeSource(CreateFrames(64)) //pass IEnumera { FrameRate = 30 //set source frame rate }; -FFMpegArguments - .FromPipeInput(videoFramesSource, ) - .OutputToFile("temporary.mp4", false, ) - .ProcessSynchronously(); +await FFMpegArguments + .FromPipeInput(videoFramesSource) + .OutputToFile(outputPath, false, options => options + .WithVideoCodec(VideoCodec.LibVpx)) + .ProcessAsynchronously(); ``` if you want to use `System.Drawing.Bitmap` as `IVideoFrame`, there is a `BitmapVideoFrameWrapper` wrapper class. @@ -179,13 +179,19 @@ If these folders are not defined, it will try to find the binaries in `/root/(ff #### Option 1 -The default value (`\\FFMPEG\\bin`) can be overwritten via the `FFMpegOptions` class: +The default value of an empty string (expecting ffmpeg to be found through PATH) can be overwritten via the `FFOptions` class: ```c# -public Startup() -{ - FFMpegOptions.Configure(new FFMpegOptions { RootDirectory = "./bin", TempDirectory = "/tmp" }); -} +// setting global options +GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" }); +// or +GlobalFFOptions.Configure(options => options.BinaryFolder = "./bin"); + +// or individual, per-run options +await FFMpegArguments + .FromFileInput(inputPath) + .OutputToFile(outputPath) + .ProcessAsynchronously(true, new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" }); ``` #### Option 2 @@ -194,8 +200,8 @@ The root and temp directory for the ffmpeg binaries can be configured via the `f ```json { - "RootDirectory": "./bin", - "TempDirectory": "/tmp" + "BinaryFolder": "./bin", + "TemporaryFilesFolder": "/tmp" } ``` @@ -217,6 +223,6 @@ The root and temp directory for the ffmpeg binaries can be configured via the `f ### License -Copyright © 2020 +Copyright © 2021 Released under [MIT license](https://github.com/rosenbjerg/FFMpegCore/blob/master/LICENSE)