mirror of
https://github.com/rosenbjerg/FFMpegCore.git
synced 2024-11-10 00:24:14 +01:00
Merge pull request #291 from rosenbjerg/master
V.4.7.0
Former-commit-id: ebbeca2dd5
This commit is contained in:
commit
a74866c08a
22 changed files with 656 additions and 32 deletions
|
@ -98,7 +98,7 @@ IEnumerable<IVideoFrame> CreateFrames(int count)
|
|||
yield return GetNextFrame(); //method of generating new frames
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var videoFramesSource = new RawVideoPipeSource(CreateFrames(64)) //pass IEnumerable<IVideoFrame> or IEnumerator<IVideoFrame> to constructor of RawVideoPipeSource
|
||||
{
|
||||
FrameRate = 30 //set source frame rate
|
||||
|
@ -115,10 +115,20 @@ await FFMpegArguments
|
|||
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" });
|
||||
|
||||
// or combined, setting global defaults and adapting per-run options
|
||||
GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "./globalTmp", WorkingDirectory = "./" });
|
||||
|
||||
await FFMpegArguments
|
||||
.FromFileInput(inputPath)
|
||||
.OutputToFile(outputPath)
|
||||
.Configure(options => options.WorkingDirectory = "./CurrentRunWorkingDir")
|
||||
.Configure(options => options.TemporaryFilesFolder = "./CurrentRunTmpFolder")
|
||||
.ProcessAsynchronously();
|
||||
}
|
98
FFMpegCore.Test/FFMpegArgumentProcessorTest.cs
Normal file
98
FFMpegCore.Test/FFMpegArgumentProcessorTest.cs
Normal file
|
@ -0,0 +1,98 @@
|
|||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using FluentAssertions;
|
||||
using System.Reflection;
|
||||
using FFMpegCore.Arguments;
|
||||
|
||||
namespace FFMpegCore.Test
|
||||
{
|
||||
[TestClass]
|
||||
public class FFMpegArgumentProcessorTest
|
||||
{
|
||||
[TestCleanup]
|
||||
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);
|
||||
}
|
||||
|
||||
private static FFMpegArgumentProcessor CreateArgumentProcessor() => FFMpegArguments
|
||||
.FromFileInput("")
|
||||
.OutputToFile("");
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Processor_GlobalOptions_GetUsed()
|
||||
{
|
||||
var globalWorkingDir = "Whatever";
|
||||
GlobalFFOptions.Configure(new FFOptions { WorkingDirectory = globalWorkingDir });
|
||||
|
||||
var processor = CreateArgumentProcessor();
|
||||
var options2 = processor.GetConfiguredOptions(null);
|
||||
options2.WorkingDirectory.Should().Be(globalWorkingDir);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Processor_SessionOptions_GetUsed()
|
||||
{
|
||||
var sessionWorkingDir = "./CurrentRunWorkingDir";
|
||||
|
||||
var processor = CreateArgumentProcessor();
|
||||
processor.Configure(options => options.WorkingDirectory = sessionWorkingDir);
|
||||
var options = processor.GetConfiguredOptions(null);
|
||||
|
||||
options.WorkingDirectory.Should().Be(sessionWorkingDir);
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Processor_Options_CanBeOverridden_And_Configured()
|
||||
{
|
||||
var globalConfig = "Whatever";
|
||||
GlobalFFOptions.Configure(new FFOptions { WorkingDirectory = globalConfig, TemporaryFilesFolder = globalConfig, BinaryFolder = globalConfig });
|
||||
|
||||
|
||||
var processor = CreateArgumentProcessor();
|
||||
|
||||
var sessionTempDir = "./CurrentRunWorkingDir";
|
||||
processor.Configure(options => options.TemporaryFilesFolder = sessionTempDir);
|
||||
|
||||
var overrideOptions = new FFOptions() { WorkingDirectory = "override" };
|
||||
var options = processor.GetConfiguredOptions(overrideOptions);
|
||||
|
||||
options.Should().BeEquivalentTo(overrideOptions);
|
||||
options.TemporaryFilesFolder.Should().BeEquivalentTo(sessionTempDir);
|
||||
options.BinaryFolder.Should().NotBeEquivalentTo(globalConfig);
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void Options_Global_And_Session_Options_Can_Differ()
|
||||
{
|
||||
FFMpegArgumentProcessor CreateArgumentProcessor() => FFMpegArguments
|
||||
.FromFileInput("")
|
||||
.OutputToFile("");
|
||||
|
||||
var globalWorkingDir = "Whatever";
|
||||
GlobalFFOptions.Configure(new FFOptions { WorkingDirectory = globalWorkingDir });
|
||||
|
||||
var processor1 = CreateArgumentProcessor();
|
||||
var sessionWorkingDir = "./CurrentRunWorkingDir";
|
||||
processor1.Configure(options => options.WorkingDirectory = sessionWorkingDir);
|
||||
var options1 = processor1.GetConfiguredOptions(null);
|
||||
options1.WorkingDirectory.Should().Be(sessionWorkingDir);
|
||||
|
||||
|
||||
var processor2 = CreateArgumentProcessor();
|
||||
var options2 = processor2.GetConfiguredOptions(null);
|
||||
options2.WorkingDirectory.Should().Be(globalWorkingDir);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Concat_Escape()
|
||||
{
|
||||
var arg = new DemuxConcatArgument(new[] { @"Heaven's River\05 - Investigation.m4b" });
|
||||
arg.Values.Should().BeEquivalentTo(new[] { @"file 'Heaven'\''s River\05 - Investigation.m4b'" });
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,10 +39,11 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.3.0" />
|
||||
<PackageReference Include="GitHubActionsTestLogger" Version="1.2.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.7" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.7" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="2.2.8" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="2.2.8" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
@ -50,6 +51,43 @@ public async Task FrameAnalysis_Async()
|
|||
Assert.IsTrue(frameAnalysis.Frames.All(f => f.MediaType == "video"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task PacketAnalysis_Async()
|
||||
{
|
||||
var packetAnalysis = await FFProbe.GetPacketsAsync(TestResources.WebmVideo);
|
||||
var packets = packetAnalysis.Packets;
|
||||
Assert.AreEqual(96, packets.Count);
|
||||
Assert.IsTrue(packets.All(f => f.CodecType == "video"));
|
||||
Assert.AreEqual("K_", packets[0].Flags);
|
||||
Assert.AreEqual(1362, packets.Last().Size);
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
public void PacketAnalysis_Sync()
|
||||
{
|
||||
var packets = FFProbe.GetPackets(TestResources.WebmVideo).Packets;
|
||||
|
||||
Assert.AreEqual(96, packets.Count);
|
||||
Assert.IsTrue(packets.All(f => f.CodecType == "video"));
|
||||
Assert.AreEqual("K_", packets[0].Flags);
|
||||
Assert.AreEqual(1362, packets.Last().Size);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PacketAnalysisAudioVideo_Sync()
|
||||
{
|
||||
var packets = FFProbe.GetPackets(TestResources.Mp4Video).Packets;
|
||||
|
||||
Assert.AreEqual(216, packets.Count);
|
||||
var actual = packets.Select(f => f.CodecType).Distinct().ToList();
|
||||
var expected = new List<string> {"audio", "video"};
|
||||
CollectionAssert.AreEquivalent(expected, actual);
|
||||
Assert.IsTrue(packets.Where(t=>t.CodecType == "audio").All(f => f.Flags == "K_"));
|
||||
Assert.AreEqual(75, packets.Count(t => t.CodecType == "video"));
|
||||
Assert.AreEqual(141, packets.Count(t => t.CodecType == "audio"));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("0:00:03.008000", 0, 0, 0, 3, 8)]
|
||||
[DataRow("05:12:59.177", 0, 5, 12, 59, 177)]
|
||||
|
|
54
FFMpegCore.Test/MetaDataBuilderTests.cs
Normal file
54
FFMpegCore.Test/MetaDataBuilderTests.cs
Normal file
|
@ -0,0 +1,54 @@
|
|||
using FFMpegCore.Builders.MetaData;
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.Test
|
||||
{
|
||||
[TestClass]
|
||||
public class MetaDataBuilderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void TestMetaDataBuilderIntegrity()
|
||||
{
|
||||
var source = new
|
||||
{
|
||||
Album = "Kanon und Gigue",
|
||||
Artist = "Pachelbel",
|
||||
Title = "Kanon und Gigue in D-Dur",
|
||||
Copyright = "Copyright Lol",
|
||||
Composer = "Pachelbel",
|
||||
Genres = new[] { "Synthwave", "Classics" },
|
||||
Tracks = new[]
|
||||
{
|
||||
new { Duration = TimeSpan.FromSeconds(10), Title = "Chapter 01" },
|
||||
new { Duration = TimeSpan.FromSeconds(10), Title = "Chapter 02" },
|
||||
new { Duration = TimeSpan.FromSeconds(10), Title = "Chapter 03" },
|
||||
new { Duration = TimeSpan.FromSeconds(10), Title = "Chapter 04" },
|
||||
}
|
||||
};
|
||||
|
||||
var builder = new MetaDataBuilder()
|
||||
.WithTitle(source.Title)
|
||||
.WithArtists(source.Artist)
|
||||
.WithComposers(source.Composer)
|
||||
.WithAlbumArtists(source.Artist)
|
||||
.WithGenres(source.Genres)
|
||||
.WithCopyright(source.Copyright)
|
||||
.AddChapters(source.Tracks, x => (x.Duration, x.Title));
|
||||
|
||||
var metadata = builder.Build();
|
||||
var serialized = MetaDataSerializer.Instance.Serialize(metadata);
|
||||
|
||||
Assert.IsTrue(serialized.StartsWith(";FFMETADATA1", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(serialized.Contains("genre=Synthwave; Classics", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(serialized.Contains("title=Chapter 01", StringComparison.OrdinalIgnoreCase));
|
||||
Assert.IsTrue(serialized.Contains("album_artist=Pachelbel", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -72,6 +72,19 @@ public void Video_ToMP4_Args()
|
|||
Assert.IsTrue(success);
|
||||
}
|
||||
|
||||
[TestMethod, Timeout(10000)]
|
||||
public void Video_ToH265_MKV_Args()
|
||||
{
|
||||
using var outputFile = new TemporaryFile($"out.mkv");
|
||||
|
||||
var success = FFMpegArguments
|
||||
.FromFileInput(TestResources.WebmVideo)
|
||||
.OutputToFile(outputFile, false, opt => opt
|
||||
.WithVideoCodec(VideoCodec.LibX265))
|
||||
.ProcessSynchronously();
|
||||
Assert.IsTrue(success);
|
||||
}
|
||||
|
||||
[DataTestMethod, Timeout(10000)]
|
||||
[DataRow(System.Drawing.Imaging.PixelFormat.Format24bppRgb)]
|
||||
[DataRow(System.Drawing.Imaging.PixelFormat.Format32bppArgb)]
|
||||
|
|
|
@ -16,8 +16,17 @@ public class DemuxConcatArgument : IInputArgument
|
|||
public readonly IEnumerable<string> Values;
|
||||
public DemuxConcatArgument(IEnumerable<string> values)
|
||||
{
|
||||
Values = values.Select(value => $"file '{value}'");
|
||||
Values = values.Select(value => $"file '{Escape(value)}'");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Thanks slhck
|
||||
/// https://superuser.com/a/787651/1089628
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
private string Escape(string value) => value.Replace("'", @"'\''");
|
||||
|
||||
private readonly string _tempFileName = Path.Combine(GlobalFFOptions.Current.TemporaryFilesFolder, $"concat_{Guid.NewGuid()}.txt");
|
||||
|
||||
public void Pre() => File.WriteAllLines(_tempFileName, Values);
|
||||
|
|
27
FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs
Normal file
27
FFMpegCore/FFMpeg/Arguments/MetaDataArgument.cs
Normal file
|
@ -0,0 +1,27 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.Arguments
|
||||
{
|
||||
public class MetaDataArgument : IInputArgument
|
||||
{
|
||||
private readonly string _metaDataContent;
|
||||
private readonly string _tempFileName = Path.Combine(GlobalFFOptions.Current.TemporaryFilesFolder, $"metadata_{Guid.NewGuid()}.txt");
|
||||
|
||||
public MetaDataArgument(string metaDataContent)
|
||||
{
|
||||
_metaDataContent = metaDataContent;
|
||||
}
|
||||
|
||||
public string Text => $"-i \"{_tempFileName}\" -map_metadata 1";
|
||||
|
||||
public Task During(CancellationToken cancellationToken = default) => Task.CompletedTask;
|
||||
|
||||
|
||||
public void Pre() => File.WriteAllText(_tempFileName, _metaDataContent);
|
||||
|
||||
public void Post() => File.Delete(_tempFileName);
|
||||
}
|
||||
}
|
18
FFMpegCore/FFMpeg/Builders/MetaData/ChapterData.cs
Normal file
18
FFMpegCore/FFMpeg/Builders/MetaData/ChapterData.cs
Normal file
|
@ -0,0 +1,18 @@
|
|||
using System;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
public class ChapterData
|
||||
{
|
||||
public string Title { get; private set; }
|
||||
public TimeSpan Start { get; private set; }
|
||||
public TimeSpan End { get; private set; }
|
||||
|
||||
public ChapterData(string title, TimeSpan start, TimeSpan end)
|
||||
{
|
||||
Title = title;
|
||||
Start = start;
|
||||
End = end;
|
||||
}
|
||||
}
|
||||
}
|
11
FFMpegCore/FFMpeg/Builders/MetaData/IReadOnlyMetaData.cs
Normal file
11
FFMpegCore/FFMpeg/Builders/MetaData/IReadOnlyMetaData.cs
Normal file
|
@ -0,0 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
|
||||
public interface IReadOnlyMetaData
|
||||
{
|
||||
IReadOnlyList<ChapterData> Chapters { get; }
|
||||
IReadOnlyDictionary<string, string> Entries { get; }
|
||||
}
|
||||
}
|
33
FFMpegCore/FFMpeg/Builders/MetaData/MetaData.cs
Normal file
33
FFMpegCore/FFMpeg/Builders/MetaData/MetaData.cs
Normal file
|
@ -0,0 +1,33 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
public class MetaData : IReadOnlyMetaData
|
||||
{
|
||||
public Dictionary<string, string> Entries { get; private set; }
|
||||
public List<ChapterData> Chapters { get; private set; }
|
||||
|
||||
IReadOnlyList<ChapterData> IReadOnlyMetaData.Chapters => this.Chapters;
|
||||
IReadOnlyDictionary<string, string> IReadOnlyMetaData.Entries => this.Entries;
|
||||
|
||||
public MetaData()
|
||||
{
|
||||
Entries = new Dictionary<string, string>();
|
||||
Chapters = new List<ChapterData>();
|
||||
}
|
||||
|
||||
public MetaData(MetaData cloneSource)
|
||||
{
|
||||
Entries = new Dictionary<string, string>(cloneSource.Entries);
|
||||
Chapters = cloneSource.Chapters
|
||||
.Select(x => new ChapterData
|
||||
(
|
||||
start: x.Start,
|
||||
end: x.End,
|
||||
title: x.Title
|
||||
))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
109
FFMpegCore/FFMpeg/Builders/MetaData/MetaDataBuilder.cs
Normal file
109
FFMpegCore/FFMpeg/Builders/MetaData/MetaDataBuilder.cs
Normal file
|
@ -0,0 +1,109 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
public class MetaDataBuilder
|
||||
{
|
||||
private MetaData _metaData = new MetaData();
|
||||
|
||||
public MetaDataBuilder WithEntry(string key, string entry)
|
||||
{
|
||||
if (_metaData.Entries.TryGetValue(key, out var value) && !string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
entry = String.Concat(value, "; ", entry);
|
||||
}
|
||||
|
||||
_metaData.Entries[key] = entry;
|
||||
return this;
|
||||
}
|
||||
|
||||
public MetaDataBuilder WithEntry(string key, params string[] values)
|
||||
=> this.WithEntry(key, String.Join("; ", values));
|
||||
|
||||
public MetaDataBuilder WithEntry(string key, IEnumerable<string> values)
|
||||
=> this.WithEntry(key, String.Join("; ", values));
|
||||
|
||||
public MetaDataBuilder AddChapter(ChapterData chapterData)
|
||||
{
|
||||
_metaData.Chapters.Add(chapterData);
|
||||
return this;
|
||||
}
|
||||
|
||||
public MetaDataBuilder AddChapters<T>(IEnumerable<T> values, Func<T, (TimeSpan duration, string title)> chapterGetter)
|
||||
{
|
||||
foreach (T value in values)
|
||||
{
|
||||
var (duration, title) = chapterGetter(value);
|
||||
AddChapter(duration, title);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public MetaDataBuilder AddChapter(TimeSpan duration, string? title = null)
|
||||
{
|
||||
var start = _metaData.Chapters.LastOrDefault()?.End ?? TimeSpan.Zero;
|
||||
var end = start + duration;
|
||||
title = String.IsNullOrEmpty(title) ? $"Chapter {_metaData.Chapters.Count + 1}" : title;
|
||||
|
||||
_metaData.Chapters.Add(new ChapterData
|
||||
(
|
||||
start: start,
|
||||
end: end,
|
||||
title: title ?? String.Empty
|
||||
));
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
//major_brand=M4A
|
||||
public MetaDataBuilder WithMajorBrand(string value) => WithEntry("major_brand", value);
|
||||
|
||||
//minor_version=512
|
||||
public MetaDataBuilder WithMinorVersion(string value) => WithEntry("minor_version", value);
|
||||
|
||||
//compatible_brands=M4A isomiso2
|
||||
public MetaDataBuilder WithCompatibleBrands(string value) => WithEntry("compatible_brands", value);
|
||||
|
||||
//copyright=©2017 / 2019 Dennis E. Taylor / Random House Audio / Wilhelm Heyne Verlag. Übersetzung von Urban Hofstetter (P)2019 Random House Audio
|
||||
public MetaDataBuilder WithCopyright(string value) => WithEntry("copyright", value);
|
||||
|
||||
//title=Alle diese Welten: Bobiverse 3
|
||||
public MetaDataBuilder WithTitle(string value) => WithEntry("title", value);
|
||||
|
||||
//artist=Dennis E. Taylor
|
||||
public MetaDataBuilder WithArtists(params string[] value) => WithEntry("artist", value);
|
||||
public MetaDataBuilder WithArtists(IEnumerable<string> value) => WithEntry("artist", value);
|
||||
|
||||
//composer=J. K. Rowling
|
||||
public MetaDataBuilder WithComposers(params string[] value) => WithEntry("composer", value);
|
||||
public MetaDataBuilder WithComposers(IEnumerable<string> value) => WithEntry("composer", value);
|
||||
|
||||
//album_artist=Dennis E. Taylor
|
||||
public MetaDataBuilder WithAlbumArtists(params string[] value) => WithEntry("album_artist", value);
|
||||
public MetaDataBuilder WithAlbumArtists(IEnumerable<string> value) => WithEntry("album_artist", value);
|
||||
|
||||
//album=Alle diese Welten: Bobiverse 3
|
||||
public MetaDataBuilder WithAlbum(string value) => WithEntry("album", value);
|
||||
|
||||
//date=2019
|
||||
public MetaDataBuilder WithDate(string value) => WithEntry("date", value);
|
||||
|
||||
//genre=Hörbuch
|
||||
public MetaDataBuilder WithGenres(params string[] value) => WithEntry("genre", value);
|
||||
public MetaDataBuilder WithGenres(IEnumerable<string> value) => WithEntry("genre", value);
|
||||
|
||||
//comment=Chapter 200
|
||||
public MetaDataBuilder WithComments(params string[] value) => WithEntry("comment", value);
|
||||
public MetaDataBuilder WithComments(IEnumerable<string> value) => WithEntry("comment", value);
|
||||
|
||||
//encoder=Lavf58.47.100
|
||||
public MetaDataBuilder WithEncoder(string value) => WithEntry("encoder", value);
|
||||
|
||||
|
||||
|
||||
public ReadOnlyMetaData Build() => new ReadOnlyMetaData(_metaData);
|
||||
}
|
||||
}
|
38
FFMpegCore/FFMpeg/Builders/MetaData/MetaDataSerializer.cs
Normal file
38
FFMpegCore/FFMpeg/Builders/MetaData/MetaDataSerializer.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
public class MetaDataSerializer
|
||||
{
|
||||
public static readonly MetaDataSerializer Instance = new MetaDataSerializer();
|
||||
|
||||
public string Serialize(IReadOnlyMetaData metaData)
|
||||
{
|
||||
var sb = new StringBuilder()
|
||||
.AppendLine(";FFMETADATA1");
|
||||
|
||||
foreach (var value in metaData.Entries)
|
||||
{
|
||||
sb.AppendLine($"{value.Key}={value.Value}");
|
||||
}
|
||||
|
||||
int chapterNumber = 0;
|
||||
foreach (var chapter in metaData.Chapters ?? Enumerable.Empty<ChapterData>())
|
||||
{
|
||||
chapterNumber++;
|
||||
var title = string.IsNullOrEmpty(chapter.Title) ? $"Chapter {chapterNumber}" : chapter.Title;
|
||||
|
||||
sb
|
||||
.AppendLine("[CHAPTER]")
|
||||
.AppendLine($"TIMEBASE=1/1000")
|
||||
.AppendLine($"START={(int)chapter.Start.TotalMilliseconds}")
|
||||
.AppendLine($"END={(int)chapter.End.TotalMilliseconds}")
|
||||
.AppendLine($"title={title}")
|
||||
;
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
25
FFMpegCore/FFMpeg/Builders/MetaData/ReadOnlyMetaData.cs
Normal file
25
FFMpegCore/FFMpeg/Builders/MetaData/ReadOnlyMetaData.cs
Normal file
|
@ -0,0 +1,25 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace FFMpegCore.Builders.MetaData
|
||||
{
|
||||
public class ReadOnlyMetaData : IReadOnlyMetaData
|
||||
{
|
||||
public IReadOnlyDictionary<string, string> Entries { get; private set; }
|
||||
public IReadOnlyList<ChapterData> Chapters { get; private set; }
|
||||
|
||||
public ReadOnlyMetaData(MetaData metaData)
|
||||
{
|
||||
Entries = new Dictionary<string, string>(metaData.Entries);
|
||||
Chapters = metaData.Chapters
|
||||
.Select(x => new ChapterData
|
||||
(
|
||||
start: x.Start,
|
||||
end: x.End,
|
||||
title: x.Title
|
||||
))
|
||||
.ToList()
|
||||
.AsReadOnly();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ public enum CodecType
|
|||
public static class VideoCodec
|
||||
{
|
||||
public static Codec LibX264 => FFMpeg.GetCodec("libx264");
|
||||
public static Codec LibX265 => FFMpeg.GetCodec("libx265");
|
||||
public static Codec LibVpx => FFMpeg.GetCodec("libvpx");
|
||||
public static Codec LibTheora => FFMpeg.GetCodec("libtheora");
|
||||
public static Codec Png => FFMpeg.GetCodec("png");
|
||||
|
|
|
@ -14,6 +14,7 @@ namespace FFMpegCore
|
|||
public class FFMpegArgumentProcessor
|
||||
{
|
||||
private static readonly Regex ProgressRegex = new Regex(@"time=(\d\d:\d\d:\d\d.\d\d?)", RegexOptions.Compiled);
|
||||
private readonly List<Action<FFOptions>> _configurations;
|
||||
private readonly FFMpegArguments _ffMpegArguments;
|
||||
private Action<double>? _onPercentageProgress;
|
||||
private Action<TimeSpan>? _onTimeProgress;
|
||||
|
@ -22,24 +23,40 @@ public class FFMpegArgumentProcessor
|
|||
|
||||
internal FFMpegArgumentProcessor(FFMpegArguments ffMpegArguments)
|
||||
{
|
||||
_configurations = new List<Action<FFOptions>>();
|
||||
_ffMpegArguments = ffMpegArguments;
|
||||
}
|
||||
|
||||
public string Arguments => _ffMpegArguments.Text;
|
||||
|
||||
private event EventHandler<int> CancelEvent = null!;
|
||||
private event EventHandler<int> CancelEvent = null!;
|
||||
|
||||
/// <summary>
|
||||
/// Register action that will be invoked during the ffmpeg processing, when a progress time is output and parsed and progress percentage is calculated.
|
||||
/// Total time is needed to calculate the percentage that has been processed of the full file.
|
||||
/// </summary>
|
||||
/// <param name="onPercentageProgress">Action to invoke when progress percentage is updated</param>
|
||||
/// <param name="totalTimeSpan">The total timespan of the mediafile being processed</param>
|
||||
public FFMpegArgumentProcessor NotifyOnProgress(Action<double> onPercentageProgress, TimeSpan totalTimeSpan)
|
||||
{
|
||||
_totalTimespan = totalTimeSpan;
|
||||
_onPercentageProgress = onPercentageProgress;
|
||||
return this;
|
||||
}
|
||||
/// <summary>
|
||||
/// Register action that will be invoked during the ffmpeg processing, when a progress time is output and parsed
|
||||
/// </summary>
|
||||
/// <param name="onTimeProgress">Action that will be invoked with the parsed timestamp as argument</param>
|
||||
public FFMpegArgumentProcessor NotifyOnProgress(Action<TimeSpan> onTimeProgress)
|
||||
{
|
||||
_onTimeProgress = onTimeProgress;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register action that will be invoked during the ffmpeg processing, when a line is output
|
||||
/// </summary>
|
||||
/// <param name="onOutput"></param>
|
||||
public FFMpegArgumentProcessor NotifyOnOutput(Action<string, DataType> onOutput)
|
||||
{
|
||||
_onOutput = onOutput;
|
||||
|
@ -55,10 +72,15 @@ public FFMpegArgumentProcessor CancellableThrough(CancellationToken token, int t
|
|||
token.Register(() => CancelEvent?.Invoke(this, timeout));
|
||||
return this;
|
||||
}
|
||||
public FFMpegArgumentProcessor Configure(Action<FFOptions> configureOptions)
|
||||
{
|
||||
_configurations.Add(configureOptions);
|
||||
return this;
|
||||
}
|
||||
public bool ProcessSynchronously(bool throwOnError = true, FFOptions? ffMpegOptions = null)
|
||||
{
|
||||
using var instance = PrepareInstance(ffMpegOptions ?? GlobalFFOptions.Current, out var cancellationTokenSource);
|
||||
var errorCode = -1;
|
||||
var options = GetConfiguredOptions(ffMpegOptions);
|
||||
using var instance = PrepareInstance(options, out var cancellationTokenSource);
|
||||
|
||||
void OnCancelEvent(object sender, int timeout)
|
||||
{
|
||||
|
@ -72,7 +94,8 @@ void OnCancelEvent(object sender, int timeout)
|
|||
}
|
||||
CancelEvent += OnCancelEvent;
|
||||
instance.Exited += delegate { cancellationTokenSource.Cancel(); };
|
||||
|
||||
|
||||
var errorCode = -1;
|
||||
try
|
||||
{
|
||||
errorCode = Process(instance, cancellationTokenSource).ConfigureAwait(false).GetAwaiter().GetResult();
|
||||
|
@ -85,14 +108,14 @@ void OnCancelEvent(object sender, int timeout)
|
|||
{
|
||||
CancelEvent -= OnCancelEvent;
|
||||
}
|
||||
|
||||
|
||||
return HandleCompletion(throwOnError, errorCode, instance.ErrorData);
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessAsynchronously(bool throwOnError = true, FFOptions? ffMpegOptions = null)
|
||||
{
|
||||
using var instance = PrepareInstance(ffMpegOptions ?? GlobalFFOptions.Current, out var cancellationTokenSource);
|
||||
var errorCode = -1;
|
||||
var options = GetConfiguredOptions(ffMpegOptions);
|
||||
using var instance = PrepareInstance(options, out var cancellationTokenSource);
|
||||
|
||||
void OnCancelEvent(object sender, int timeout)
|
||||
{
|
||||
|
@ -105,7 +128,8 @@ void OnCancelEvent(object sender, int timeout)
|
|||
}
|
||||
}
|
||||
CancelEvent += OnCancelEvent;
|
||||
|
||||
|
||||
var errorCode = -1;
|
||||
try
|
||||
{
|
||||
errorCode = await Process(instance, cancellationTokenSource).ConfigureAwait(false);
|
||||
|
@ -148,6 +172,18 @@ private bool HandleCompletion(bool throwOnError, int exitCode, IReadOnlyList<str
|
|||
return exitCode == 0;
|
||||
}
|
||||
|
||||
internal FFOptions GetConfiguredOptions(FFOptions? ffOptions)
|
||||
{
|
||||
var options = ffOptions ?? GlobalFFOptions.Current.Clone();
|
||||
|
||||
foreach (var configureOptions in _configurations)
|
||||
{
|
||||
configureOptions(options);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
private Instance PrepareInstance(FFOptions ffOptions,
|
||||
out CancellationTokenSource cancellationTokenSource)
|
||||
{
|
||||
|
@ -170,7 +206,7 @@ private Instance PrepareInstance(FFOptions ffOptions,
|
|||
return instance;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private static bool HandleException(bool throwOnError, Exception e, IReadOnlyList<string> errorData)
|
||||
{
|
||||
if (!throwOnError)
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FFMpegCore.Arguments;
|
||||
using FFMpegCore.Builders.MetaData;
|
||||
using FFMpegCore.Pipes;
|
||||
|
||||
namespace FFMpegCore
|
||||
|
@ -38,6 +39,8 @@ public FFMpegArguments WithGlobalOptions(Action<FFMpegGlobalArguments> configure
|
|||
public FFMpegArguments AddFileInput(FileInfo fileInfo, Action<FFMpegArgumentOptions>? addArguments = null) => WithInput(new InputArgument(fileInfo.FullName, false), addArguments);
|
||||
public FFMpegArguments AddUrlInput(Uri uri, Action<FFMpegArgumentOptions>? addArguments = null) => WithInput(new InputArgument(uri.AbsoluteUri, false), addArguments);
|
||||
public FFMpegArguments AddPipeInput(IPipeSource sourcePipe, Action<FFMpegArgumentOptions>? addArguments = null) => WithInput(new InputPipeArgument(sourcePipe), addArguments);
|
||||
public FFMpegArguments AddMetaData(string content, Action<FFMpegArgumentOptions>? addArguments = null) => WithInput(new MetaDataArgument(content), addArguments);
|
||||
public FFMpegArguments AddMetaData(IReadOnlyMetaData metaData, Action<FFMpegArgumentOptions>? addArguments = null) => WithInput(new MetaDataArgument(MetaDataSerializer.Instance.Serialize(metaData)), addArguments);
|
||||
|
||||
private FFMpegArguments WithInput(IInputArgument inputArgument, Action<FFMpegArgumentOptions>? addArguments)
|
||||
{
|
||||
|
|
|
@ -8,18 +8,13 @@
|
|||
<Description>A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications</Description>
|
||||
<AssemblyVersion>4.0.0.0</AssemblyVersion>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageReleaseNotes>- Support for reading Disposition metadata on streams in FFProbe output (thanks alex6dj)
|
||||
- Fix for stream index in Snapshot(Async) (thanks stasokrosh)
|
||||
- Add support for frame analysis through FFProbe,GetFrames(Async) (thanks andyjmorgan)
|
||||
- Fix for subtitle burning error, when subtitle path contains special characters (thanks alex6dj)
|
||||
- Support for Audio filters (thanks alex6dj)
|
||||
- Fix FFProbe.AnalyseAsync not throwing if non-zero exit-code (thanks samburovkv)
|
||||
- Change bit_rate from int to long to support analysis of high bit-rate files (thanks zhuker)
|
||||
- Support for specifying working directory for ffmpeg and ffprobe processes through FFOptions
|
||||
- Ensure Image instances in JoinImageSequence are disposed
|
||||
- Added ConfigureAwait(false) to prevent hanging with certain frameworks</PackageReleaseNotes>
|
||||
<PackageReleaseNotes>- Added libx265 static codec prop
|
||||
- Support for reading Packets from mediafile through ffprobe (thanks zhuker)
|
||||
- Support for fluent configuration of FFOptinos per-run (thanks BobSilent)
|
||||
- Support for adding metadata (thanks Weirdo)
|
||||
- Automatically escape single quotes in filenames for DemuxConcatArgument (thanks JKamsker)</PackageReleaseNotes>
|
||||
<LangVersion>8</LangVersion>
|
||||
<PackageVersion>4.6.0</PackageVersion>
|
||||
<PackageVersion>4.7.0</PackageVersion>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Authors>Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev</Authors>
|
||||
<PackageTags>ffmpeg ffprobe convert video audio mediafile resize analyze muxing</PackageTags>
|
||||
|
@ -37,7 +32,7 @@
|
|||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Instances" Version="1.6.0" />
|
||||
<PackageReference Include="Instances" Version="1.6.1" />
|
||||
<PackageReference Include="System.Drawing.Common" Version="5.0.2" />
|
||||
<PackageReference Include="System.Text.Json" Version="5.0.1" />
|
||||
</ItemGroup>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
using System.Collections.Generic;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace FFMpegCore
|
||||
{
|
||||
public class FFOptions
|
||||
public class FFOptions : ICloneable
|
||||
{
|
||||
/// <summary>
|
||||
/// Working directory for the ffmpeg/ffprobe instance
|
||||
|
@ -27,16 +28,24 @@ public class FFOptions
|
|||
public Encoding Encoding { get; set; } = Encoding.Default;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
///
|
||||
/// </summary>
|
||||
public Dictionary<string, string> ExtensionOverrides { get; set; } = new Dictionary<string, string>
|
||||
{
|
||||
{ "mpegts", ".ts" },
|
||||
};
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Whether to cache calls to get ffmpeg codec, pixel- and container-formats
|
||||
/// </summary>
|
||||
public bool UseCache { get; set; } = true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
object ICloneable.Clone() => Clone();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new object that is a copy of the current instance.
|
||||
/// </summary>
|
||||
public FFOptions Clone() => (FFOptions)MemberwiseClone();
|
||||
}
|
||||
}
|
|
@ -37,6 +37,20 @@ public static FFProbeFrames GetFrames(string filePath, int outputCapacity = int.
|
|||
|
||||
return ParseFramesOutput(instance);
|
||||
}
|
||||
|
||||
public static FFProbePackets GetPackets(string filePath, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
throw new FFMpegException(FFMpegExceptionType.File, $"No file found at '{filePath}'");
|
||||
|
||||
using var instance = PreparePacketAnalysisInstance(filePath, outputCapacity, ffOptions ?? GlobalFFOptions.Current);
|
||||
var exitCode = instance.BlockUntilFinished();
|
||||
if (exitCode != 0)
|
||||
throw new FFMpegException(FFMpegExceptionType.Process, $"ffprobe exited with non-zero exit-code ({exitCode} - {string.Join("\n", instance.ErrorData)})", null, string.Join("\n", instance.ErrorData));
|
||||
|
||||
return ParsePacketsOutput(instance);
|
||||
}
|
||||
|
||||
public static IMediaAnalysis Analyse(Uri uri, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null)
|
||||
{
|
||||
using var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, outputCapacity, ffOptions ?? GlobalFFOptions.Current);
|
||||
|
@ -91,6 +105,17 @@ public static async Task<FFProbeFrames> GetFramesAsync(string filePath, int outp
|
|||
await instance.FinishedRunning().ConfigureAwait(false);
|
||||
return ParseFramesOutput(instance);
|
||||
}
|
||||
|
||||
public static async Task<FFProbePackets> GetPacketsAsync(string filePath, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null)
|
||||
{
|
||||
if (!File.Exists(filePath))
|
||||
throw new FFMpegException(FFMpegExceptionType.File, $"No file found at '{filePath}'");
|
||||
|
||||
using var instance = PreparePacketAnalysisInstance(filePath, outputCapacity, ffOptions ?? GlobalFFOptions.Current);
|
||||
await instance.FinishedRunning().ConfigureAwait(false);
|
||||
return ParsePacketsOutput(instance);
|
||||
}
|
||||
|
||||
public static async Task<IMediaAnalysis> AnalyseAsync(Uri uri, int outputCapacity = int.MaxValue, FFOptions? ffOptions = null)
|
||||
{
|
||||
using var instance = PrepareStreamAnalysisInstance(uri.AbsoluteUri, outputCapacity, ffOptions ?? GlobalFFOptions.Current);
|
||||
|
@ -152,11 +177,25 @@ private static FFProbeFrames ParseFramesOutput(Instance instance)
|
|||
return ffprobeAnalysis;
|
||||
}
|
||||
|
||||
private static FFProbePackets ParsePacketsOutput(Instance instance)
|
||||
{
|
||||
var json = string.Join(string.Empty, instance.OutputData);
|
||||
var ffprobeAnalysis = JsonSerializer.Deserialize<FFProbePackets>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString | System.Text.Json.Serialization.JsonNumberHandling.WriteAsString
|
||||
}) ;
|
||||
|
||||
return ffprobeAnalysis;
|
||||
}
|
||||
|
||||
|
||||
private static Instance PrepareStreamAnalysisInstance(string filePath, int outputCapacity, FFOptions ffOptions)
|
||||
=> PrepareInstance($"-loglevel error -print_format json -show_format -sexagesimal -show_streams \"{filePath}\"", outputCapacity, ffOptions);
|
||||
private static Instance PrepareFrameAnalysisInstance(string filePath, int outputCapacity, FFOptions ffOptions)
|
||||
=> PrepareInstance($"-loglevel error -print_format json -show_frames -v quiet -sexagesimal \"{filePath}\"", outputCapacity, ffOptions);
|
||||
private static Instance PreparePacketAnalysisInstance(string filePath, int outputCapacity, FFOptions ffOptions)
|
||||
=> PrepareInstance($"-loglevel error -print_format json -show_packets -v quiet -sexagesimal \"{filePath}\"", outputCapacity, ffOptions);
|
||||
|
||||
private static Instance PrepareInstance(string arguments, int outputCapacity, FFOptions ffOptions)
|
||||
{
|
||||
|
|
47
FFMpegCore/FFProbe/PacketAnalysis.cs
Normal file
47
FFMpegCore/FFProbe/PacketAnalysis.cs
Normal file
|
@ -0,0 +1,47 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FFMpegCore
|
||||
{
|
||||
public class FFProbePacketAnalysis
|
||||
{
|
||||
[JsonPropertyName("codec_type")]
|
||||
public string CodecType { get; set; }
|
||||
|
||||
[JsonPropertyName("stream_index")]
|
||||
public int StreamIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("pts")]
|
||||
public long Pts { get; set; }
|
||||
|
||||
[JsonPropertyName("pts_time")]
|
||||
public string PtsTime { get; set; }
|
||||
|
||||
[JsonPropertyName("dts")]
|
||||
public long Dts { get; set; }
|
||||
|
||||
[JsonPropertyName("dts_time")]
|
||||
public string DtsTime { get; set; }
|
||||
|
||||
[JsonPropertyName("duration")]
|
||||
public int Duration { get; set; }
|
||||
|
||||
[JsonPropertyName("duration_time")]
|
||||
public string DurationTime { get; set; }
|
||||
|
||||
[JsonPropertyName("size")]
|
||||
public int Size { get; set; }
|
||||
|
||||
[JsonPropertyName("pos")]
|
||||
public long Pos { get; set; }
|
||||
|
||||
[JsonPropertyName("flags")]
|
||||
public string Flags { get; set; }
|
||||
}
|
||||
|
||||
public class FFProbePackets
|
||||
{
|
||||
[JsonPropertyName("packets")]
|
||||
public List<FFProbePacketAnalysis> Packets { get; set; }
|
||||
}
|
||||
}
|
12
README.md
12
README.md
|
@ -182,7 +182,17 @@ await FFMpegArguments
|
|||
.FromFileInput(inputPath)
|
||||
.OutputToFile(outputPath)
|
||||
.ProcessAsynchronously(true, new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" });
|
||||
```
|
||||
|
||||
// or combined, setting global defaults and adapting per-run options
|
||||
GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "./globalTmp", WorkingDirectory = "./" });
|
||||
|
||||
await FFMpegArguments
|
||||
.FromFileInput(inputPath)
|
||||
.OutputToFile(outputPath)
|
||||
.Configure(options => options.WorkingDirectory = "./CurrentRunWorkingDir")
|
||||
.Configure(options => options.TemporaryFilesFolder = "./CurrentRunTmpFolder")
|
||||
.ProcessAsynchronously();
|
||||
```
|
||||
|
||||
### Option 2
|
||||
|
||||
|
|
Loading…
Reference in a new issue