diff --git a/FFMpegCore.Test/FFProbeTests.cs b/FFMpegCore.Test/FFProbeTests.cs index a702eed..2ee5a92 100644 --- a/FFMpegCore.Test/FFProbeTests.cs +++ b/FFMpegCore.Test/FFProbeTests.cs @@ -1,4 +1,6 @@ -using FFMpegCore.Test.Resources; +using FFMpegCore.Exceptions; +using FFMpegCore.Helpers; +using FFMpegCore.Test.Resources; namespace FFMpegCore.Test; @@ -285,4 +287,68 @@ public class FFProbeTests var info = FFProbe.Analyse(TestResources.Mp4Video, customArguments: "-headers \"Hello: World\""); Assert.AreEqual(3, info.Duration.Seconds); } + + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task Parallel_FFProbe_Cancellation_Should_Throw_Only_OperationCanceledException() + { + // Warm up FFMpegCore environment + FFProbeHelper.VerifyFFProbeExists(GlobalFFOptions.Current); + + var mp4 = TestResources.Mp4Video; + if (!File.Exists(mp4)) + { + Assert.Inconclusive($"Test video not found: {mp4}"); + return; + } + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); + using var semaphore = new SemaphoreSlim(Environment.ProcessorCount, Environment.ProcessorCount); + var tasks = Enumerable.Range(0, 50).Select(x => Task.Run(async () => + { + await semaphore.WaitAsync(cts.Token); + try + { + var analysis = await FFProbe.AnalyseAsync(mp4, cancellationToken: cts.Token); + return analysis; + } + finally + { + semaphore.Release(); + } + }, cts.Token)).ToList(); + + // Wait for 2 tasks to finish, then cancel all + await Task.WhenAny(tasks); + await Task.WhenAny(tasks); + await cts.CancelAsync(); + + var exceptions = new List(); + foreach (var task in tasks) + { + try + { + await task; + } + catch (Exception e) + { + exceptions.Add(e); + } + } + + Assert.IsNotEmpty(exceptions, "No exceptions were thrown on cancellation. Test was useless. " + + ".Try adjust cancellation timings to make cancellation at the moment, when ffprobe is still running."); + + // Check that all exceptions are OperationCanceledException + CollectionAssert.AllItemsAreInstancesOfType(exceptions, typeof(OperationCanceledException)); + } + + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task FFProbe_Should_Throw_FFMpegException_When_Exits_With_Non_Zero_Code() + { + var input = TestResources.SrtSubtitle; //non media file + await Assert.ThrowsAsync(async () => await FFProbe.AnalyseAsync(input, + cancellationToken: TestContext.CancellationToken, customArguments: "--some-invalid-argument")); + } } diff --git a/FFMpegCore.Test/VideoTest.cs b/FFMpegCore.Test/VideoTest.cs index 07c435d..b0a85aa 100644 --- a/FFMpegCore.Test/VideoTest.cs +++ b/FFMpegCore.Test/VideoTest.cs @@ -82,6 +82,40 @@ public class VideoTest Assert.IsTrue(success); } + [TestMethod] + [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] + public async Task Video_MetadataBuilder() + { + using var outputFile = new TemporaryFile($"out{VideoType.Mp4.Extension}"); + + await FFMpegArguments + .FromFileInput(TestResources.WebmVideo) + .AddMetaData(FFMetadataBuilder.Empty() + .WithTag("title", "noname") + .WithTag("artist", "unknown") + .WithChapter("Chapter 1", 1.1) + .WithChapter("Chapter 2", 1.23)) + .OutputToFile(outputFile, false, opt => opt + .WithVideoCodec(VideoCodec.LibX264)) + .CancellableThrough(TestContext.CancellationToken) + .ProcessAsynchronously(); + + var analysis = await FFProbe.AnalyseAsync(outputFile, cancellationToken: TestContext.CancellationToken); + Assert.IsTrue(analysis.Format.Tags!.TryGetValue("title", out var title)); + Assert.IsTrue(analysis.Format.Tags!.TryGetValue("artist", out var artist)); + Assert.AreEqual("noname", title); + Assert.AreEqual("unknown", artist); + + Assert.HasCount(2, analysis.Chapters); + Assert.AreEqual("Chapter 1", analysis.Chapters.First().Title); + Assert.AreEqual(1.1, analysis.Chapters.First().Duration.TotalSeconds); + Assert.AreEqual(1.1, analysis.Chapters.First().End.TotalSeconds); + + Assert.AreEqual("Chapter 2", analysis.Chapters.Last().Title); + Assert.AreEqual(1.23, analysis.Chapters.Last().Duration.TotalSeconds); + Assert.AreEqual(1.1 + 1.23, analysis.Chapters.Last().End.TotalSeconds); + } + [TestMethod] [Timeout(BaseTimeoutMilliseconds, CooperativeCancellation = true)] public void Video_ToH265_MKV_Args() @@ -1018,7 +1052,7 @@ public class VideoTest { using var outputFile = new TemporaryFile("out.mp4"); - var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); var task = FFMpegArguments .FromFileInput("testsrc2=size=320x240[out0]; sine[out1]", false, args => args @@ -1029,7 +1063,6 @@ public class VideoTest .WithVideoCodec(VideoCodec.LibX264) .WithSpeedPreset(Speed.VeryFast)) .CancellableThrough(cts.Token) - .CancellableThrough(TestContext.CancellationToken) .ProcessAsynchronously(false); cts.CancelAfter(300); @@ -1045,7 +1078,7 @@ public class VideoTest { using var outputFile = new TemporaryFile("out.mp4"); - var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); var task = FFMpegArguments .FromFileInput("testsrc2=size=320x240[out0]; sine[out1]", false, args => args @@ -1056,7 +1089,6 @@ public class VideoTest .WithVideoCodec(VideoCodec.LibX264) .WithSpeedPreset(Speed.VeryFast)) .CancellableThrough(cts.Token) - .CancellableThrough(TestContext.CancellationToken) .ProcessAsynchronously(); cts.CancelAfter(300); @@ -1070,7 +1102,7 @@ public class VideoTest { using var outputFile = new TemporaryFile("out.mp4"); - var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); var task = FFMpegArguments .FromFileInput("testsrc2=size=320x240[out0]; sine[out1]", false, args => args @@ -1094,7 +1126,7 @@ public class VideoTest { using var outputFile = new TemporaryFile("out.mp4"); - var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); cts.Cancel(); var task = FFMpegArguments @@ -1117,7 +1149,7 @@ public class VideoTest { using var outputFile = new TemporaryFile("out.mp4"); - var cts = new CancellationTokenSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.CancellationToken); var task = FFMpegArguments .FromFileInput("testsrc2=size=320x240[out0]; sine[out1]", false, args => args diff --git a/FFMpegCore/FFMetadataInputArgument.cs b/FFMpegCore/FFMetadataInputArgument.cs new file mode 100644 index 0000000..f2fce59 --- /dev/null +++ b/FFMpegCore/FFMetadataInputArgument.cs @@ -0,0 +1,62 @@ +using System.Text; + +namespace FFMpegCore; + +public class FFMetadataBuilder +{ + private Dictionary Tags { get; } = new(); + private List Chapters { get; } = []; + + public static FFMetadataBuilder Empty() + { + return new FFMetadataBuilder(); + } + + public FFMetadataBuilder WithTag(string key, string value) + { + Tags.Add(key, value); + return this; + } + + public FFMetadataBuilder WithChapter(string title, long durationMs) + { + Chapters.Add(new FFMetadataChapter(title, durationMs)); + return this; + } + + public FFMetadataBuilder WithChapter(string title, double durationSeconds) + { + Chapters.Add(new FFMetadataChapter(title, Convert.ToInt64(durationSeconds * 1000))); + return this; + } + + public string GetMetadataFileContent() + { + var sb = new StringBuilder(); + sb.AppendLine(";FFMETADATA1"); + + foreach (var tag in Tags) + { + sb.AppendLine($"{tag.Key}={tag.Value}"); + } + + long totalDurationMs = 0; + foreach (var chapter in Chapters) + { + sb.AppendLine("[CHAPTER]"); + sb.AppendLine("TIMEBASE=1/1000"); + sb.AppendLine($"START={totalDurationMs}"); + sb.AppendLine($"END={totalDurationMs + chapter.DurationMs}"); + sb.AppendLine($"title={chapter.Title}"); + totalDurationMs += chapter.DurationMs; + } + + return sb.ToString(); + } + + private class FFMetadataChapter(string title, long durationMs) + { + public string Title { get; } = title; + public long DurationMs { get; } = durationMs; + } +} diff --git a/FFMpegCore/FFMpeg/FFMpegArguments.cs b/FFMpegCore/FFMpeg/FFMpegArguments.cs index 4c3b7bf..927442d 100644 --- a/FFMpegCore/FFMpeg/FFMpegArguments.cs +++ b/FFMpegCore/FFMpeg/FFMpegArguments.cs @@ -109,6 +109,11 @@ public sealed class FFMpegArguments : FFMpegArgumentsBase return WithInput(new MetaDataArgument(content), addArguments); } + public FFMpegArguments AddMetaData(FFMetadataBuilder metaDataBuilder, Action? addArguments = null) + { + return WithInput(new MetaDataArgument(metaDataBuilder.GetMetadataFileContent()), addArguments); + } + public FFMpegArguments AddMetaData(IReadOnlyMetaData metaData, Action? addArguments = null) { return WithInput(new MetaDataArgument(MetaDataSerializer.Instance.Serialize(metaData)), addArguments); diff --git a/FFMpegCore/FFMpegCore.csproj b/FFMpegCore/FFMpegCore.csproj index 671239d..df567ad 100644 --- a/FFMpegCore/FFMpegCore.csproj +++ b/FFMpegCore/FFMpegCore.csproj @@ -3,13 +3,14 @@ true A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications - 5.3.0 + 5.4.0 ../nupkg - - **Fixed race condition on Named pipe dispose/disconnect** by techtel-pstevens - - **More extensions for snapshot function(jpg, bmp, webp)** by GorobVictor - - **Include more GUID characters in pipe path** by reima, rosenbjerg - - **Updated dependencies and minor cleanup**: by rosenbjerg + - Fixed exception thrown on cancelling ffprobe analysis - by snechaev + - Added FFMetadataBuilder - by rosenbjerg + - Fix JoinImageSequence by passing framerate argument to input as well as output - by rosenbjerg + - Change fps input from int to double - by rosenbjerg + - Fix GetCreationTime method on ITagsContainer - by rosenbjerg ffmpeg ffprobe convert video audio mediafile resize analyze muxing Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev diff --git a/FFMpegCore/FFProbe/FFProbe.cs b/FFMpegCore/FFProbe/FFProbe.cs index e199376..164ea72 100644 --- a/FFMpegCore/FFProbe/FFProbe.cs +++ b/FFMpegCore/FFProbe/FFProbe.cs @@ -84,6 +84,7 @@ public static class FFProbe var instance = PrepareStreamAnalysisInstance(filePath, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); ThrowIfExitCodeNotZero(result); return ParseOutput(result); @@ -123,6 +124,7 @@ public static class FFProbe { var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, ffOptions ?? GlobalFFOptions.Current, customArguments); var result = await instance.StartAndWaitForExitAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); ThrowIfExitCodeNotZero(result); return ParseOutput(result); @@ -150,6 +152,7 @@ public static class FFProbe } var result = await task.ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); ThrowIfExitCodeNotZero(result); pipeArgument.Post(); diff --git a/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs b/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs index ef7140a..6682e94 100644 --- a/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs +++ b/FFMpegCore/FFProbe/ProcessArgumentsExtensions.cs @@ -13,6 +13,6 @@ public static class ProcessArgumentsExtensions public static async Task StartAndWaitForExitAsync(this ProcessArguments processArguments, CancellationToken cancellationToken = default) { using var instance = processArguments.Start(); - return await instance.WaitForExitAsync(cancellationToken); + return await instance.WaitForExitAsync(cancellationToken).ConfigureAwait(false); } }