mirror of
https://github.com/rosenbjerg/FFMpegCore.git
synced 2024-11-10 08:34:12 +01:00
commit
b301c44f46
23 changed files with 446 additions and 135 deletions
|
@ -231,6 +231,13 @@ public void Builder_BuildString_FrameOutputCount()
|
|||
Assert.AreEqual("-i \"input.mp4\" -vframes 50 \"output.mp4\"", str);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Builder_BuildString_VideoStreamNumber()
|
||||
{
|
||||
var str = FFMpegArguments.FromFileInput("input.mp4").OutputToFile("output.mp4", false, opt => opt.SelectStream(1)).Arguments;
|
||||
Assert.AreEqual("-i \"input.mp4\" -map 0:1 \"output.mp4\"", str);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Builder_BuildString_FrameRate()
|
||||
{
|
||||
|
@ -317,6 +324,27 @@ public void Builder_BuildString_DrawtextFilter_Alt()
|
|||
str);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Builder_BuildString_SubtitleHardBurnFilter()
|
||||
{
|
||||
var str = FFMpegArguments
|
||||
.FromFileInput("input.mp4")
|
||||
.OutputToFile("output.mp4", false, opt => opt
|
||||
.WithVideoFilters(filterOptions => filterOptions
|
||||
.HardBurnSubtitle(SubtitleHardBurnOptions
|
||||
.Create(subtitlePath: "sample.srt")
|
||||
.SetCharacterEncoding("UTF-8")
|
||||
.SetOriginalSize(1366,768)
|
||||
.SetSubtitleIndex(0)
|
||||
.WithStyle(StyleOptions.Create()
|
||||
.WithParameter("FontName", "DejaVu Serif")
|
||||
.WithParameter("PrimaryColour", "&HAA00FF00")))))
|
||||
.Arguments;
|
||||
|
||||
Assert.AreEqual("-i \"input.mp4\" -vf \"subtitles=sample.srt:charenc=UTF-8:original_size=1366x768:stream_index=0:force_style='FontName=DejaVu Serif\\,PrimaryColour=&HAA00FF00'\" \"output.mp4\"",
|
||||
str);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Builder_BuildString_StartNumber()
|
||||
{
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using FFMpegCore.Extend;
|
||||
|
||||
namespace FFMpegCore.Test
|
||||
{
|
||||
|
|
|
@ -83,6 +83,9 @@
|
|||
<None Update="Resources\mute.mp4">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Resources\sample.srt">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -63,6 +63,8 @@ public void Probe_Success()
|
|||
Assert.AreEqual("LC", info.PrimaryAudioStream.Profile);
|
||||
Assert.AreEqual(377351, info.PrimaryAudioStream.BitRate);
|
||||
Assert.AreEqual(48000, info.PrimaryAudioStream.SampleRateHz);
|
||||
Assert.AreEqual("mp4a", info.PrimaryAudioStream.CodecTagString);
|
||||
Assert.AreEqual("0x6134706d", info.PrimaryAudioStream.CodecTag);
|
||||
|
||||
Assert.AreEqual(1471810, info.PrimaryVideoStream!.BitRate);
|
||||
Assert.AreEqual(16, info.PrimaryVideoStream.DisplayAspectRatio.Width);
|
||||
|
@ -76,6 +78,8 @@ public void Probe_Success()
|
|||
Assert.AreEqual("h264", info.PrimaryVideoStream.CodecName);
|
||||
Assert.AreEqual(8, info.PrimaryVideoStream.BitsPerRawSample);
|
||||
Assert.AreEqual("Main", info.PrimaryVideoStream.Profile);
|
||||
Assert.AreEqual("avc1", info.PrimaryVideoStream.CodecTagString);
|
||||
Assert.AreEqual("0x31637661", info.PrimaryVideoStream.CodecTag);
|
||||
}
|
||||
|
||||
[TestMethod, Timeout(10000)]
|
||||
|
@ -100,5 +104,15 @@ public async Task Probe_Success_FromStream_Async()
|
|||
var info = await FFProbe.AnalyseAsync(stream);
|
||||
Assert.AreEqual(3, info.Duration.Seconds);
|
||||
}
|
||||
|
||||
[TestMethod, Timeout(10000)]
|
||||
public async Task Probe_Success_Subtitle_Async()
|
||||
{
|
||||
var info = await FFProbe.AnalyseAsync(TestResources.SrtSubtitle);
|
||||
Assert.IsNotNull(info.PrimarySubtitleStream);
|
||||
Assert.AreEqual(1, info.SubtitleStreams.Count);
|
||||
Assert.AreEqual(0, info.AudioStreams.Count);
|
||||
Assert.AreEqual(0, info.VideoStreams.Count);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,5 +20,6 @@ public static class TestResources
|
|||
public static readonly string Mp3Audio = "./Resources/audio.mp3";
|
||||
public static readonly string PngImage = "./Resources/cover.png";
|
||||
public static readonly string ImageCollection = "./Resources/images";
|
||||
public static readonly string SrtSubtitle = "./Resources/sample.srt";
|
||||
}
|
||||
}
|
||||
|
|
12
FFMpegCore.Test/Resources/sample.srt
Normal file
12
FFMpegCore.Test/Resources/sample.srt
Normal file
|
@ -0,0 +1,12 @@
|
|||
1
|
||||
00:00:00,000 --> 00:00:01,500
|
||||
For www.forom.com
|
||||
|
||||
2
|
||||
00:00:01,500 --> 00:00:02,500
|
||||
<i>Tonight's the night.</i>
|
||||
|
||||
3
|
||||
00:00:03,000 --> 00:00:15,000
|
||||
<i>And it's going to happen
|
||||
again and again --</i>
|
|
@ -655,7 +655,7 @@ public async Task Video_Cancel_CancellationToken_Async_With_Timeout()
|
|||
.WithAudioCodec(AudioCodec.Aac)
|
||||
.WithVideoCodec(VideoCodec.LibX264)
|
||||
.WithSpeedPreset(Speed.VeryFast))
|
||||
.CancellableThrough(cts.Token, 5000)
|
||||
.CancellableThrough(cts.Token, 8000)
|
||||
.ProcessAsynchronously(false);
|
||||
|
||||
await Task.Delay(300);
|
||||
|
|
24
FFMpegCore/Extend/KeyValuePairExtensions.cs
Normal file
24
FFMpegCore/Extend/KeyValuePairExtensions.cs
Normal file
|
@ -0,0 +1,24 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace FFMpegCore.Extend
|
||||
{
|
||||
internal static class KeyValuePairExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Concat the two members of a <see cref="KeyValuePair{TKey,TValue}" />
|
||||
/// </summary>
|
||||
/// <param name="pair">Input object</param>
|
||||
/// <param name="enclose">
|
||||
/// If true encloses the value part between quotes if contains an space character. If false use the
|
||||
/// value unmodified
|
||||
/// </param>
|
||||
/// <returns>The formatted string</returns>
|
||||
public static string FormatArgumentPair(this KeyValuePair<string, string> pair, bool enclose)
|
||||
{
|
||||
var key = pair.Key;
|
||||
var value = enclose ? pair.Value.EncloseIfContainsSpace() : pair.Value;
|
||||
|
||||
return $"{key}={value}";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,10 +1,12 @@
|
|||
using FFMpegCore.Pipes;
|
||||
using System.IO;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FFMpegCore.Pipes;
|
||||
|
||||
public class PcmAudioSampleWrapper : IAudioSample
|
||||
namespace FFMpegCore.Extend
|
||||
{
|
||||
public class PcmAudioSampleWrapper : IAudioSample
|
||||
{
|
||||
//This could actually be short or int, but copies would be inefficient.
|
||||
//Handling bytes lets the user decide on the conversion, and abstract the library
|
||||
//from handling shorts, unsigned shorts, integers, unsigned integers and floats.
|
||||
|
@ -24,4 +26,5 @@ public async Task SerializeAsync(Stream stream, CancellationToken token)
|
|||
{
|
||||
await stream.WriteAsync(_sample, 0, _sample.Length, token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
FFMpegCore/Extend/StringExtensions.cs
Normal file
15
FFMpegCore/Extend/StringExtensions.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
namespace FFMpegCore.Extend
|
||||
{
|
||||
internal static class StringExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enclose string between quotes if contains an space character
|
||||
/// </summary>
|
||||
/// <param name="input">The input</param>
|
||||
/// <returns>The enclosed string</returns>
|
||||
public static string EncloseIfContainsSpace(this string input)
|
||||
{
|
||||
return input.Contains(" ") ? $"'{input}'" : input;
|
||||
}
|
||||
}
|
||||
}
|
19
FFMpegCore/FFMpeg/Arguments/MapStreamArgument.cs
Normal file
19
FFMpegCore/FFMpeg/Arguments/MapStreamArgument.cs
Normal file
|
@ -0,0 +1,19 @@
|
|||
namespace FFMpegCore.Arguments
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents choice of video stream
|
||||
/// </summary>
|
||||
public class MapStreamArgument : IArgument
|
||||
{
|
||||
private readonly int _inputFileIndex;
|
||||
private readonly int _streamIndex;
|
||||
|
||||
public MapStreamArgument(int streamIndex, int inputFileIndex)
|
||||
{
|
||||
_inputFileIndex = inputFileIndex;
|
||||
_streamIndex = streamIndex;
|
||||
}
|
||||
|
||||
public string Text => $"-map {_inputFileIndex}:{_streamIndex}";
|
||||
}
|
||||
}
|
132
FFMpegCore/FFMpeg/Arguments/SubtitleHardBurnArgument.cs
Normal file
132
FFMpegCore/FFMpeg/Arguments/SubtitleHardBurnArgument.cs
Normal file
|
@ -0,0 +1,132 @@
|
|||
using FFMpegCore.Extend;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
|
||||
namespace FFMpegCore.Arguments
|
||||
{
|
||||
public class SubtitleHardBurnArgument : IVideoFilterArgument
|
||||
{
|
||||
private readonly SubtitleHardBurnOptions _subtitleHardBurnOptions;
|
||||
|
||||
public SubtitleHardBurnArgument(SubtitleHardBurnOptions subtitleHardBurnOptions)
|
||||
{
|
||||
_subtitleHardBurnOptions = subtitleHardBurnOptions;
|
||||
}
|
||||
|
||||
public string Key => "subtitles";
|
||||
|
||||
public string Value => _subtitleHardBurnOptions.TextInternal;
|
||||
}
|
||||
|
||||
public class SubtitleHardBurnOptions
|
||||
{
|
||||
private readonly string _subtitle;
|
||||
|
||||
public readonly Dictionary<string, string> Parameters = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a new <see cref="SubtitleHardBurnOptions"/> using a provided subtitle file or a video file
|
||||
/// containing one.
|
||||
/// </summary>
|
||||
/// <param name="subtitlePath"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>Only support .srt and .ass files, and subrip and ssa subtitle streams</remarks>
|
||||
public static SubtitleHardBurnOptions Create(string subtitlePath)
|
||||
{
|
||||
return new SubtitleHardBurnOptions(subtitlePath);
|
||||
}
|
||||
|
||||
private SubtitleHardBurnOptions(string subtitle)
|
||||
{
|
||||
_subtitle = subtitle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specify the size of the original video, the video for which the ASS file was composed.
|
||||
/// </summary>
|
||||
/// <param name="width"></param>
|
||||
/// <param name="height"></param>
|
||||
/// <returns></returns>
|
||||
public SubtitleHardBurnOptions SetOriginalSize(int width, int height)
|
||||
{
|
||||
return WithParameter("original_size", $"{width}x{height}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Specify the size of the original video, the video for which the ASS file was composed.
|
||||
/// </summary>
|
||||
/// <param name="size"></param>
|
||||
/// <returns></returns>
|
||||
public SubtitleHardBurnOptions SetOriginalSize(Size size)
|
||||
{
|
||||
return SetOriginalSize(size.Width, size.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set subtitles stream index.
|
||||
/// </summary>
|
||||
/// <param name="index"></param>
|
||||
/// <returns></returns>
|
||||
/// <remarks>
|
||||
/// Used when the provided subtitle is an stream of a video file (ex. .mkv) with multiple subtitles.
|
||||
/// Represent the index of the subtitle not the stream, them the first subtitle index is 0 and second is 1
|
||||
/// </remarks>
|
||||
public SubtitleHardBurnOptions SetSubtitleIndex(int index)
|
||||
{
|
||||
return WithParameter("stream_index", index.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set subtitles input character encoding. Only useful if not UTF-8
|
||||
/// </summary>
|
||||
/// <param name="encode">Charset encoding</param>
|
||||
/// <returns></returns>
|
||||
public SubtitleHardBurnOptions SetCharacterEncoding(string encode)
|
||||
{
|
||||
return WithParameter("charenc", encode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Override default style or script info parameters of the subtitles
|
||||
/// </summary>
|
||||
/// <param name="styleOptions"></param>
|
||||
/// <returns></returns>
|
||||
public SubtitleHardBurnOptions WithStyle(StyleOptions styleOptions)
|
||||
{
|
||||
return WithParameter("force_style", styleOptions.TextInternal);
|
||||
}
|
||||
|
||||
public SubtitleHardBurnOptions WithParameter(string key, string value)
|
||||
{
|
||||
Parameters.Add(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
internal string TextInternal => string.Join(":", new[] { _subtitle.EncloseIfContainsSpace() }.Concat(Parameters.Select(parameter => parameter.FormatArgumentPair(enclose: true))));
|
||||
}
|
||||
|
||||
public class StyleOptions
|
||||
{
|
||||
public readonly Dictionary<string, string> Parameters = new Dictionary<string, string>();
|
||||
|
||||
public static StyleOptions Create()
|
||||
{
|
||||
return new StyleOptions();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Used to override default style or script info parameters of the subtitles. It accepts ASS style format
|
||||
/// </summary>
|
||||
/// <param name="key"></param>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
public StyleOptions WithParameter(string key, string value)
|
||||
{
|
||||
Parameters.Add(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
internal string TextInternal => string.Join(",", Parameters.Select(parameter => parameter.FormatArgumentPair(enclose: false)));
|
||||
}
|
||||
}
|
|
@ -50,6 +50,7 @@ public class VideoFilterOptions
|
|||
public VideoFilterOptions Transpose(Transposition transposition) => WithArgument(new TransposeArgument(transposition));
|
||||
public VideoFilterOptions Mirror(Mirroring mirroring) => WithArgument(new SetMirroringArgument(mirroring));
|
||||
public VideoFilterOptions DrawText(DrawTextOptions drawTextOptions) => WithArgument(new DrawTextArgument(drawTextOptions));
|
||||
public VideoFilterOptions HardBurnSubtitle(SubtitleHardBurnOptions subtitleHardBurnOptions) => WithArgument(new SubtitleHardBurnArgument(subtitleHardBurnOptions));
|
||||
|
||||
private VideoFilterOptions WithArgument(IVideoFilterArgument argument)
|
||||
{
|
||||
|
|
|
@ -20,14 +20,16 @@ public static class FFMpeg
|
|||
/// <param name="output">Output video file path</param>
|
||||
/// <param name="captureTime">Seek position where the thumbnail should be taken.</param>
|
||||
/// <param name="size">Thumbnail size. If width or height equal 0, the other will be computed automatically.</param>
|
||||
/// <param name="streamIndex">Selected video stream index.</param>
|
||||
/// <param name="inputFileIndex">Input file index</param>
|
||||
/// <returns>Bitmap with the requested snapshot.</returns>
|
||||
public static bool Snapshot(string input, string output, Size? size = null, TimeSpan? captureTime = null)
|
||||
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);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex);
|
||||
|
||||
return arguments
|
||||
.OutputToFile(output, true, outputOptions)
|
||||
|
@ -40,14 +42,16 @@ public static bool Snapshot(string input, string output, Size? size = null, Time
|
|||
/// <param name="output">Output video file path</param>
|
||||
/// <param name="captureTime">Seek position where the thumbnail should be taken.</param>
|
||||
/// <param name="size">Thumbnail size. If width or height equal 0, the other will be computed automatically.</param>
|
||||
/// <param name="streamIndex">Selected video stream index.</param>
|
||||
/// <param name="inputFileIndex">Input file index</param>
|
||||
/// <returns>Bitmap with the requested snapshot.</returns>
|
||||
public static async Task<bool> SnapshotAsync(string input, string output, Size? size = null, TimeSpan? captureTime = null)
|
||||
public static async Task<bool> 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);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex);
|
||||
|
||||
return await arguments
|
||||
.OutputToFile(output, true, outputOptions)
|
||||
|
@ -60,11 +64,13 @@ public static async Task<bool> SnapshotAsync(string input, string output, Size?
|
|||
/// <param name="input">Source video file.</param>
|
||||
/// <param name="captureTime">Seek position where the thumbnail should be taken.</param>
|
||||
/// <param name="size">Thumbnail size. If width or height equal 0, the other will be computed automatically.</param>
|
||||
/// <param name="streamIndex">Selected video stream index.</param>
|
||||
/// <param name="inputFileIndex">Input file index</param>
|
||||
/// <returns>Bitmap with the requested snapshot.</returns>
|
||||
public static Bitmap Snapshot(string input, Size? size = null, TimeSpan? captureTime = null)
|
||||
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);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex);
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
arguments
|
||||
|
@ -82,11 +88,13 @@ public static Bitmap Snapshot(string input, Size? size = null, TimeSpan? capture
|
|||
/// <param name="input">Source video file.</param>
|
||||
/// <param name="captureTime">Seek position where the thumbnail should be taken.</param>
|
||||
/// <param name="size">Thumbnail size. If width or height equal 0, the other will be computed automatically.</param>
|
||||
/// <param name="streamIndex">Selected video stream index.</param>
|
||||
/// <param name="inputFileIndex">Input file index</param>
|
||||
/// <returns>Bitmap with the requested snapshot.</returns>
|
||||
public static async Task<Bitmap> SnapshotAsync(string input, Size? size = null, TimeSpan? captureTime = null)
|
||||
public static async Task<Bitmap> SnapshotAsync(string input, Size? size = null, TimeSpan? captureTime = null, int? streamIndex = null, int inputFileIndex = 0)
|
||||
{
|
||||
var source = await FFProbe.AnalyseAsync(input);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime);
|
||||
var (arguments, outputOptions) = BuildSnapshotArguments(input, source, size, captureTime, streamIndex, inputFileIndex);
|
||||
using var ms = new MemoryStream();
|
||||
|
||||
await arguments
|
||||
|
@ -98,15 +106,23 @@ await arguments
|
|||
return new Bitmap(ms);
|
||||
}
|
||||
|
||||
private static (FFMpegArguments, Action<FFMpegArgumentOptions> outputOptions) BuildSnapshotArguments(string input, IMediaAnalysis source, Size? size = null, TimeSpan? captureTime = null)
|
||||
private static (FFMpegArguments, Action<FFMpegArgumentOptions> outputOptions) BuildSnapshotArguments(
|
||||
string input,
|
||||
IMediaAnalysis source,
|
||||
Size? size = null,
|
||||
TimeSpan? captureTime = null,
|
||||
int? streamIndex = null,
|
||||
int inputFileIndex = 0)
|
||||
{
|
||||
captureTime ??= TimeSpan.FromSeconds(source.Duration.TotalSeconds / 3);
|
||||
size = PrepareSnapshotSize(source, size);
|
||||
streamIndex = streamIndex == null ? 0 : source.VideoStreams.FirstOrDefault(videoStream => videoStream.Index == streamIndex).Index;
|
||||
|
||||
return (FFMpegArguments
|
||||
.FromFileInput(input, false, options => options
|
||||
.Seek(captureTime)),
|
||||
options => options
|
||||
.SelectStream((int)streamIndex, inputFileIndex)
|
||||
.WithVideoCodec(VideoCodec.Png)
|
||||
.WithFrameOutputCount(1)
|
||||
.Resize(size));
|
||||
|
@ -231,10 +247,10 @@ public static bool PosterWithAudio(string image, string audio, string output)
|
|||
FFMpegHelper.ConversionSizeExceptionCheck(Image.FromFile(image));
|
||||
|
||||
return FFMpegArguments
|
||||
.FromFileInput(image)
|
||||
.FromFileInput(image, false, options => options
|
||||
.Loop(1))
|
||||
.AddFileInput(audio)
|
||||
.OutputToFile(output, true, options => options
|
||||
.Loop(1)
|
||||
.WithVideoCodec(VideoCodec.LibX264)
|
||||
.WithConstantRateFactor(21)
|
||||
.WithAudioBitrate(AudioQuality.Normal)
|
||||
|
|
|
@ -52,6 +52,7 @@ public FFMpegArgumentOptions WithVideoFilters(Action<VideoFilterOptions> videoFi
|
|||
public FFMpegArgumentOptions Seek(TimeSpan? seekTo) => WithArgument(new SeekArgument(seekTo));
|
||||
public FFMpegArgumentOptions Loop(int times) => WithArgument(new LoopArgument(times));
|
||||
public FFMpegArgumentOptions OverwriteExisting() => WithArgument(new OverwriteArgument());
|
||||
public FFMpegArgumentOptions SelectStream(int streamIndex, int inputFileIndex = 0) => WithArgument(new MapStreamArgument(streamIndex, inputFileIndex));
|
||||
|
||||
public FFMpegArgumentOptions ForceFormat(ContainerFormat format) => WithArgument(new ForceFormatArgument(format));
|
||||
public FFMpegArgumentOptions ForceFormat(string format) => WithArgument(new ForceFormatArgument(format));
|
||||
|
|
|
@ -6,14 +6,15 @@
|
|||
<PackageProjectUrl>https://github.com/rosenbjerg/FFMpegCore</PackageProjectUrl>
|
||||
<Copyright></Copyright>
|
||||
<Description>A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications</Description>
|
||||
<Version>3.0.0.0</Version>
|
||||
<AssemblyVersion>3.0.0.0</AssemblyVersion>
|
||||
<FileVersion>3.0.0.0</FileVersion>
|
||||
<PackageReleaseNotes>- Cancellation token support (thanks patagonaa)
|
||||
- Support for setting stdout and stderr encoding for ffprobe (thanks CepheiSigma)
|
||||
- Improved ffprobe exceptions</PackageReleaseNotes>
|
||||
<AssemblyVersion>4.0.0.0</AssemblyVersion>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<PackageReleaseNotes>- Support for PCM audio samples (thanks to Namaneo)
|
||||
- Support for subtitle streams in MediaAnalysis (thanks to alex6dj)
|
||||
- Support for subtitle hard burning (thanks to alex6dj)
|
||||
- Additional codec* properties on MediaAnalysis object (thanks to GuyWithDogs)
|
||||
- SelectStream method for mapping/specifyíng specific streams from input files (thanks to Feodoros)</PackageReleaseNotes>
|
||||
<LangVersion>8</LangVersion>
|
||||
<PackageVersion>4.4.0</PackageVersion>
|
||||
<PackageVersion>4.5.0</PackageVersion>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<Authors>Malte Rosenbjerg, Vlad Jerca, Max Bagryantsev</Authors>
|
||||
<PackageTags>ffmpeg ffprobe convert video audio mediafile resize analyze muxing</PackageTags>
|
||||
|
@ -27,6 +28,7 @@
|
|||
<Content Include="FFMPEG\bin\**\*">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -41,6 +41,12 @@ public class FFProbeStream : ITagsContainer
|
|||
[JsonPropertyName("codec_long_name")]
|
||||
public string CodecLongName { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("codec_tag")]
|
||||
public string CodecTag { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("codec_tag_string")]
|
||||
public string CodecTagString { get; set; } = null!;
|
||||
|
||||
[JsonPropertyName("display_aspect_ratio")]
|
||||
public string DisplayAspectRatio { get; set; } = null!;
|
||||
|
||||
|
|
|
@ -9,7 +9,9 @@ public interface IMediaAnalysis
|
|||
MediaFormat Format { get; }
|
||||
AudioStream? PrimaryAudioStream { get; }
|
||||
VideoStream? PrimaryVideoStream { get; }
|
||||
SubtitleStream? PrimarySubtitleStream { get; }
|
||||
List<VideoStream> VideoStreams { get; }
|
||||
List<AudioStream> AudioStreams { get; }
|
||||
List<SubtitleStream> SubtitleStreams { get; }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ internal MediaAnalysis(FFProbeAnalysis analysis)
|
|||
Format = ParseFormat(analysis.Format);
|
||||
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();
|
||||
}
|
||||
|
||||
private MediaFormat ParseFormat(Format analysisFormat)
|
||||
|
@ -36,12 +37,14 @@ private MediaFormat ParseFormat(Format analysisFormat)
|
|||
}.Max();
|
||||
|
||||
public MediaFormat Format { get; }
|
||||
public AudioStream? PrimaryAudioStream => AudioStreams.OrderBy(stream => stream.Index).FirstOrDefault();
|
||||
|
||||
public AudioStream? PrimaryAudioStream => AudioStreams.OrderBy(stream => stream.Index).FirstOrDefault();
|
||||
public VideoStream? PrimaryVideoStream => VideoStreams.OrderBy(stream => stream.Index).FirstOrDefault();
|
||||
public SubtitleStream? PrimarySubtitleStream => SubtitleStreams.OrderBy(stream => stream.Index).FirstOrDefault();
|
||||
|
||||
public List<VideoStream> VideoStreams { get; }
|
||||
public List<AudioStream> AudioStreams { get; }
|
||||
public List<SubtitleStream> SubtitleStreams { get; }
|
||||
|
||||
private VideoStream ParseVideoStream(FFProbeStream stream)
|
||||
{
|
||||
|
@ -53,6 +56,8 @@ private VideoStream ParseVideoStream(FFProbeStream stream)
|
|||
BitsPerRawSample = !string.IsNullOrEmpty(stream.BitsPerRawSample) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitsPerRawSample) : default,
|
||||
CodecName = stream.CodecName,
|
||||
CodecLongName = stream.CodecLongName,
|
||||
CodecTag = stream.CodecTag,
|
||||
CodecTagString = stream.CodecTagString,
|
||||
DisplayAspectRatio = MediaAnalysisUtils.ParseRatioInt(stream.DisplayAspectRatio, ':'),
|
||||
Duration = MediaAnalysisUtils.ParseDuration(stream),
|
||||
FrameRate = MediaAnalysisUtils.DivideRatio(MediaAnalysisUtils.ParseRatioDouble(stream.FrameRate, '/')),
|
||||
|
@ -74,6 +79,8 @@ private AudioStream ParseAudioStream(FFProbeStream stream)
|
|||
BitRate = !string.IsNullOrEmpty(stream.BitRate) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitRate) : default,
|
||||
CodecName = stream.CodecName,
|
||||
CodecLongName = stream.CodecLongName,
|
||||
CodecTag = stream.CodecTag,
|
||||
CodecTagString = stream.CodecTagString,
|
||||
Channels = stream.Channels ?? default,
|
||||
ChannelLayout = stream.ChannelLayout,
|
||||
Duration = MediaAnalysisUtils.ParseDuration(stream),
|
||||
|
@ -84,7 +91,19 @@ private AudioStream ParseAudioStream(FFProbeStream stream)
|
|||
};
|
||||
}
|
||||
|
||||
|
||||
private SubtitleStream ParseSubtitleStream(FFProbeStream stream)
|
||||
{
|
||||
return new SubtitleStream
|
||||
{
|
||||
Index = stream.Index,
|
||||
BitRate = !string.IsNullOrEmpty(stream.BitRate) ? MediaAnalysisUtils.ParseIntInvariant(stream.BitRate) : default,
|
||||
CodecName = stream.CodecName,
|
||||
CodecLongName = stream.CodecLongName,
|
||||
Duration = MediaAnalysisUtils.ParseDuration(stream),
|
||||
Language = stream.GetLanguage(),
|
||||
Tags = stream.Tags,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static class MediaAnalysisUtils
|
||||
|
|
|
@ -10,6 +10,8 @@ public class MediaStream
|
|||
public int Index { get; internal set; }
|
||||
public string CodecName { get; internal set; } = null!;
|
||||
public string CodecLongName { get; internal set; } = null!;
|
||||
public string CodecTagString { get; set; } = null!;
|
||||
public string CodecTag { get; set; } = null!;
|
||||
public int BitRate { get; internal set; }
|
||||
public TimeSpan Duration { get; internal set; }
|
||||
public string? Language { get; internal set; }
|
||||
|
|
7
FFMpegCore/FFProbe/SubtitleStream.cs
Normal file
7
FFMpegCore/FFProbe/SubtitleStream.cs
Normal file
|
@ -0,0 +1,7 @@
|
|||
namespace FFMpegCore
|
||||
{
|
||||
public class SubtitleStream : MediaStream
|
||||
{
|
||||
|
||||
}
|
||||
}
|
|
@ -8,18 +8,11 @@ namespace FFMpegCore
|
|||
public static class GlobalFFOptions
|
||||
{
|
||||
private static readonly string ConfigFile = "ffmpeg.config.json";
|
||||
private static FFOptions? _current;
|
||||
|
||||
public static FFOptions Current { get; private set; }
|
||||
static GlobalFFOptions()
|
||||
public static FFOptions Current
|
||||
{
|
||||
if (File.Exists(ConfigFile))
|
||||
{
|
||||
Current = JsonSerializer.Deserialize<FFOptions>(File.ReadAllText(ConfigFile))!;
|
||||
}
|
||||
else
|
||||
{
|
||||
Current = new FFOptions();
|
||||
}
|
||||
get { return _current ??= LoadFFOptions(); }
|
||||
}
|
||||
|
||||
public static void Configure(Action<FFOptions> optionsAction)
|
||||
|
@ -28,7 +21,7 @@ public static void Configure(Action<FFOptions> optionsAction)
|
|||
}
|
||||
public static void Configure(FFOptions ffOptions)
|
||||
{
|
||||
Current = ffOptions ?? throw new ArgumentNullException(nameof(ffOptions));
|
||||
_current = ffOptions ?? throw new ArgumentNullException(nameof(ffOptions));
|
||||
}
|
||||
|
||||
|
||||
|
@ -48,5 +41,17 @@ private static string GetFFBinaryPath(string name, FFOptions ffOptions)
|
|||
|
||||
return Path.Combine(ffOptions.BinaryFolder, ffName);
|
||||
}
|
||||
|
||||
private static FFOptions LoadFFOptions()
|
||||
{
|
||||
if (File.Exists(ConfigFile))
|
||||
{
|
||||
return JsonSerializer.Deserialize<FFOptions>(File.ReadAllText(ConfigFile))!;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new FFOptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
138
README.md
138
README.md
|
@ -1,37 +1,29 @@
|
|||
# FFMpegCore
|
||||
[![CI](https://github.com/rosenbjerg/FFMpegCore/workflows/CI/badge.svg)](https://github.com/rosenbjerg/FFMpegCore/actions?query=workflow%3ACI)
|
||||
# [FFMpegCore](https://www.nuget.org/packages/FFMpegCore/)
|
||||
[![NuGet Badge](https://buildstats.info/nuget/FFMpegCore)](https://www.nuget.org/packages/FFMpegCore/)
|
||||
[![GitHub issues](https://img.shields.io/github/issues/rosenbjerg/FFMpegCore)](https://github.com/rosenbjerg/FFMpegCore/issues)
|
||||
[![GitHub stars](https://img.shields.io/github/stars/rosenbjerg/FFMpegCore)](https://github.com/rosenbjerg/FFMpegCore/stargazers)
|
||||
[![GitHub](https://img.shields.io/github/license/rosenbjerg/FFMpegCore)](https://github.com/rosenbjerg/FFMpegCore/blob/master/LICENSE)
|
||||
[![CI](https://github.com/rosenbjerg/FFMpegCore/workflows/CI/badge.svg)](https://github.com/rosenbjerg/FFMpegCore/actions?query=workflow%3ACI)
|
||||
|
||||
# Setup
|
||||
|
||||
#### NuGet:
|
||||
|
||||
```
|
||||
Install-Package FFMpegCore
|
||||
```
|
||||
|
||||
A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your C# applications. Support both synchronous and asynchronous use
|
||||
A .NET Standard FFMpeg/FFProbe wrapper for easily integrating media analysis and conversion into your .NET applications. Supports both synchronous and asynchronous calls
|
||||
|
||||
# API
|
||||
|
||||
## FFProbe
|
||||
|
||||
FFProbe is used to gather media information:
|
||||
Use FFProbe to analyze media files:
|
||||
|
||||
```csharp
|
||||
var mediaInfo = FFProbe.Analyse(inputPath);
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||||
```
|
||||
or
|
||||
```csharp
|
||||
var mediaInfo = await FFProbe.AnalyseAsync(inputPath);
|
||||
var mediaInfo = FFProbe.Analyse(inputPath);
|
||||
```
|
||||
|
||||
|
||||
## FFMpeg
|
||||
FFMpeg is used for converting your media files to web ready formats.
|
||||
Use FFMpeg to convert your media files.
|
||||
Easily build your FFMpeg arguments using the fluent argument builder:
|
||||
|
||||
Convert input file to h264/aac scaled to 720p w/ faststart, for web playback
|
||||
|
@ -49,15 +41,6 @@ FFMpegArguments
|
|||
.ProcessSynchronously();
|
||||
```
|
||||
|
||||
Easily capture screens from your videos:
|
||||
```csharp
|
||||
// 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));
|
||||
```
|
||||
|
||||
Convert to and/or from streams
|
||||
```csharp
|
||||
await FFMpegArguments
|
||||
|
@ -68,7 +51,19 @@ await FFMpegArguments
|
|||
.ProcessAsynchronously();
|
||||
```
|
||||
|
||||
Join video parts into one single file:
|
||||
## Helper methods
|
||||
The provided helper methods makes it simple to perform common operations.
|
||||
|
||||
### Easily capture snapshots from a video file:
|
||||
```csharp
|
||||
// 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));
|
||||
```
|
||||
|
||||
### Join video parts into one single file:
|
||||
```csharp
|
||||
FFMpeg.Join(@"..\joined_video.mp4",
|
||||
@"..\part1.mp4",
|
||||
|
@ -77,7 +72,7 @@ FFMpeg.Join(@"..\joined_video.mp4",
|
|||
);
|
||||
```
|
||||
|
||||
Join images into a video:
|
||||
### Join images into a video:
|
||||
```csharp
|
||||
FFMpeg.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1,
|
||||
ImageInfo.FromPath(@"..\1.png"),
|
||||
|
@ -86,22 +81,22 @@ FFMpeg.JoinImageSequence(@"..\joined_video.mp4", frameRate: 1,
|
|||
);
|
||||
```
|
||||
|
||||
Mute videos:
|
||||
### Mute the audio of a video file:
|
||||
```csharp
|
||||
FFMpeg.Mute(inputPath, outputPath);
|
||||
```
|
||||
|
||||
Save audio track from video:
|
||||
### Extract the audio track from a video file:
|
||||
```csharp
|
||||
FFMpeg.ExtractAudio(inputPath, outputPath);
|
||||
```
|
||||
|
||||
Add or replace audio track on video:
|
||||
### Add or replace the audio track of a video file:
|
||||
```csharp
|
||||
FFMpeg.ReplaceAudio(inputPath, inputAudioPath, outputPath);
|
||||
```
|
||||
|
||||
Add poster image to audio file (good for youtube videos):
|
||||
### Combine an image with audio file, for youtube or similar platforms
|
||||
```csharp
|
||||
FFMpeg.PosterWithAudio(inputPath, inputAudioPath, outputPath);
|
||||
// or
|
||||
|
@ -111,26 +106,27 @@ image.AddAudio(inputAudioPath, outputPath);
|
|||
|
||||
Other available arguments could be found in `FFMpegCore.Arguments` namespace.
|
||||
|
||||
### Input piping
|
||||
With input piping it is possible to write video frames directly from program memory without saving them to jpeg or png and then passing path to input of ffmpeg. This feature also allows us to convert video on-the-fly while frames are being generated or received.
|
||||
## Input piping
|
||||
With input piping it is possible to write video frames directly from program memory without saving them to jpeg or png and then passing path to input of ffmpeg. This feature also allows for converting video on-the-fly while frames are being generated or received.
|
||||
|
||||
The `IPipeSource` interface is used as the source of data. It could be represented as encoded video stream or raw frames stream. Currently, the `IPipeSource` interface has single implementation, `RawVideoPipeSource` that is used for raw stream encoding.
|
||||
An object implementing the `IPipeSource` interface is used as the source of data. Currently, the `IPipeSource` interface has two implementations; `StreamPipeSource` for streams, and `RawVideoPipeSource` for raw video frames.
|
||||
|
||||
For example:
|
||||
### Working with raw video frames
|
||||
|
||||
Method that is generating bitmap frames:
|
||||
Method for generating bitmap frames:
|
||||
```csharp
|
||||
IEnumerable<IVideoFrame> CreateFrames(int count)
|
||||
{
|
||||
for(int i = 0; i < count; i++)
|
||||
{
|
||||
yield return GetNextFrame(); //method of generating new frames
|
||||
yield return GetNextFrame(); //method that generates of receives the next frame
|
||||
}
|
||||
}
|
||||
```
|
||||
Then create `ArgumentsContainer` with `InputPipeArgument`
|
||||
|
||||
Then create a `RawVideoPipeSource` that utilises your video frame source
|
||||
```csharp
|
||||
var videoFramesSource = new RawVideoPipeSource(CreateFrames(64)) //pass IEnumerable<IVideoFrame> or IEnumerator<IVideoFrame> to constructor of RawVideoPipeSource
|
||||
var videoFramesSource = new RawVideoPipeSource(CreateFrames(64))
|
||||
{
|
||||
FrameRate = 30 //set source frame rate
|
||||
};
|
||||
|
@ -141,52 +137,46 @@ await FFMpegArguments
|
|||
.ProcessAsynchronously();
|
||||
```
|
||||
|
||||
if you want to use `System.Drawing.Bitmap` as `IVideoFrame`, there is a `BitmapVideoFrameWrapper` wrapper class.
|
||||
If you want to use `System.Drawing.Bitmap`s as `IVideoFrame`s, a `BitmapVideoFrameWrapper` wrapper class is provided.
|
||||
|
||||
|
||||
## Binaries
|
||||
# Binaries
|
||||
|
||||
## Installation
|
||||
If you prefer to manually download them, visit [ffbinaries](https://ffbinaries.com/downloads) or [zeranoe Windows builds](https://ffmpeg.zeranoe.com/builds/).
|
||||
|
||||
#### Windows
|
||||
|
||||
command: `choco install ffmpeg -Y`
|
||||
### Windows (using choco)
|
||||
command: `choco install ffmpeg -y`
|
||||
|
||||
location: `C:\ProgramData\chocolatey\lib\ffmpeg\tools\ffmpeg\bin`
|
||||
|
||||
#### Mac OSX
|
||||
|
||||
### Mac OSX
|
||||
command: `brew install ffmpeg mono-libgdiplus`
|
||||
|
||||
location: `/usr/local/bin`
|
||||
|
||||
#### Ubuntu
|
||||
|
||||
### Ubuntu
|
||||
command: `sudo apt-get install -y ffmpeg libgdiplus`
|
||||
|
||||
location: `/usr/bin`
|
||||
|
||||
|
||||
## Path Configuration
|
||||
|
||||
#### Behavior
|
||||
|
||||
If you wish to support multiple client processor architectures, you can do so by creating a folder `x64` and `x86` in the `root` directory.
|
||||
Both folders should contain the binaries (`ffmpeg.exe` and `ffprobe.exe`) for build for the respective architectures.
|
||||
|
||||
By doing so, the library will attempt to use either `/root/{ARCH}/(ffmpeg|ffprobe).exe`.
|
||||
|
||||
If these folders are not defined, it will try to find the binaries in `/root/(ffmpeg|ffprobe.exe)`
|
||||
|
||||
#### Option 1
|
||||
### Option 1
|
||||
|
||||
The default value of an empty string (expecting ffmpeg to be found through PATH) can be overwritten via the `FFOptions` class:
|
||||
|
||||
```c#
|
||||
```csharp
|
||||
// setting global options
|
||||
GlobalFFOptions.Configure(new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" });
|
||||
|
||||
// or
|
||||
GlobalFFOptions.Configure(options => options.BinaryFolder = "./bin");
|
||||
|
||||
// on some systems the absolute path may be required, in which case
|
||||
GlobalFFOptions.Configure(new FFOptions { BinaryFolder = Server.MapPath("./bin"), TemporaryFilesFolder = Server.MapPath("/tmp") });
|
||||
|
||||
// or individual, per-run options
|
||||
await FFMpegArguments
|
||||
.FromFileInput(inputPath)
|
||||
|
@ -194,9 +184,9 @@ await FFMpegArguments
|
|||
.ProcessAsynchronously(true, new FFOptions { BinaryFolder = "./bin", TemporaryFilesFolder = "/tmp" });
|
||||
```
|
||||
|
||||
#### Option 2
|
||||
### Option 2
|
||||
|
||||
The root and temp directory for the ffmpeg binaries can be configured via the `ffmpeg.config.json` file.
|
||||
The root and temp directory for the ffmpeg binaries can be configured via the `ffmpeg.config.json` file, which will be read on first use only.
|
||||
|
||||
```json
|
||||
{
|
||||
|
@ -205,22 +195,30 @@ The root and temp directory for the ffmpeg binaries can be configured via the `f
|
|||
}
|
||||
```
|
||||
|
||||
### Supporting both 32 and 64 bit processes
|
||||
If you wish to support multiple client processor architectures, you can do so by creating a folder `x64` and `x86` in the `root` directory.
|
||||
Both folders should contain the binaries (`ffmpeg.exe` and `ffprobe.exe`) for build for the respective architectures.
|
||||
|
||||
By doing so, the library will attempt to use either `/root/{ARCH}/(ffmpeg|ffprobe).exe`.
|
||||
|
||||
If these folders are not defined, it will try to find the binaries in `/root/(ffmpeg|ffprobe.exe)`.
|
||||
|
||||
(`.exe` is only appended on Windows)
|
||||
|
||||
|
||||
# Compatibility
|
||||
Some versions of FFMPEG might not have the same argument schema. The lib has been tested with version `3.3` to `4.2`
|
||||
Older versions of ffmpeg might not support all ffmpeg arguments available through this library. The library has been tested with version `3.3` to `4.2`
|
||||
|
||||
|
||||
## Contributors
|
||||
## Code contributors
|
||||
<a href="https://github.com/rosenbjerg/ffmpegcore/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=rosenbjerg/ffmpegcore" />
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/vladjerca"><img src="https://avatars.githubusercontent.com/u/6339681?v=4" title="vladjerca" width="80" height="80"></a>
|
||||
<a href="https://github.com/max619"><img src="https://avatars.githubusercontent.com/u/26447324?v=4" title="max619" width="80" height="80"></a>
|
||||
<a href="https://github.com/kyriakosio"><img src="https://avatars3.githubusercontent.com/u/6959989?v=4" title="kyriakosio" width="80" height="80"></a>
|
||||
<a href="https://github.com/winchesterag"><img src="https://avatars3.githubusercontent.com/u/47878681?v=4" title="winchesterag" width="80" height="80"></a>
|
||||
<a href="https://github.com/devlev"><img src="https://avatars3.githubusercontent.com/u/2109995?v=4" title="devlev" width="80" height="80"></a>
|
||||
<a href="https://github.com/tugrulelmas"><img src="https://avatars3.githubusercontent.com/u/3829187?v=4" title="tugrulelmas" width="80" height="80"></a>
|
||||
<a href="https://github.com/rosenbjerg"><img src="https://avatars3.githubusercontent.com/u/11181960?v=4" title="rosenbjerg" width="80" height="80"></a>
|
||||
<a href="https://github.com/WeihanLi"><img src="https://avatars3.githubusercontent.com/u/7604648?v=4" title="weihanli" width="80" height="80"></a>
|
||||
## Non-code contributors
|
||||
<a href="https://github.com/tiesont"><img src="https://avatars3.githubusercontent.com/u/420293?v=4" title="tiesont" width="80" height="80"></a>
|
||||
|
||||
|
||||
### License
|
||||
|
||||
Copyright © 2021
|
||||
|
|
Loading…
Reference in a new issue