diff --git a/FFMpegCore.Test/ArgumentBuilderTest.cs b/FFMpegCore.Test/ArgumentBuilderTest.cs index 30adabd..cf455c8 100644 --- a/FFMpegCore.Test/ArgumentBuilderTest.cs +++ b/FFMpegCore.Test/ArgumentBuilderTest.cs @@ -571,5 +571,45 @@ public void Builder_BuildString_GifPalette_NullSize_FpsSupplied() -i "input.mp4" -filter_complex "[{streamIndex}:v] fps=10,split [a][b];[a] palettegen=max_colors=32 [p];[b][p] paletteuse=dither=bayer" "output.gif" """, str); } + + [TestMethod] + public void Builder_BuildString_MultiOutput() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .MultiOutput(args => args + .OutputToFile("output.mp4", overwrite: true, args => args.CopyChannel()) + .OutputToFile("output.ts", overwrite: false, args => args.CopyChannel().ForceFormat("mpegts")) + .OutputToUrl("http://server/path", options => options.ForceFormat("webm"))) + .Arguments; + Assert.AreEqual($""" + -i "input.mp4" -c:a copy -c:v copy "output.mp4" -y -c:a copy -c:v copy -f mpegts "output.ts" -f webm http://server/path + """, str); + } + + [TestMethod] + public void Builder_BuildString_MBROutput() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .MultiOutput(args => args + .OutputToFile("sd.mp4", overwrite: true, args => args.Resize(1200, 720)) + .OutputToFile("hd.mp4", overwrite: false, args => args.Resize(1920, 1080))) + .Arguments; + Assert.AreEqual($""" + -i "input.mp4" -s 1200x720 "sd.mp4" -y -s 1920x1080 "hd.mp4" + """, str); + } + + [TestMethod] + public void Builder_BuildString_TeeOutput() + { + var str = FFMpegArguments.FromFileInput("input.mp4") + .OutputToTee(args => args + .OutputToFile("output.mp4", overwrite: false, args => args.WithFastStart()) + .OutputToUrl("http://server/path", options => options.ForceFormat("mpegts").SelectStream(0, channel: Channel.Video))) + .Arguments; + Assert.AreEqual($""" + -i "input.mp4" -f tee "[movflags=faststart]output.mp4|[f=mpegts:select=\'0:v:0\']http://server/path" + """, str); + } } } diff --git a/FFMpegCore/FFMpeg/Arguments/OutputTeeArgument.cs b/FFMpegCore/FFMpeg/Arguments/OutputTeeArgument.cs new file mode 100644 index 0000000..974b928 --- /dev/null +++ b/FFMpegCore/FFMpeg/Arguments/OutputTeeArgument.cs @@ -0,0 +1,57 @@ + +namespace FFMpegCore.Arguments +{ + internal class OutputTeeArgument : IOutputArgument + { + private readonly FFMpegMultiOutputOptions _options; + + public OutputTeeArgument(FFMpegMultiOutputOptions options) + { + if (options.Outputs.Count == 0) + { + throw new ArgumentException("Atleast one output must be specified.", nameof(options)); + } + + _options = options; + } + + public string Text => $"-f tee \"{string.Join("|", _options.Outputs.Select(MapOptions))}\""; + + public Task During(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public void Post() + { + } + + public void Pre() + { + } + + private static string MapOptions(FFMpegArgumentOptions option) + { + var optionPrefix = string.Empty; + if (option.Arguments.Count > 1) + { + var options = option.Arguments.Take(option.Arguments.Count - 1); + optionPrefix = $"[{string.Join(":", options.Select(MapArgument))}]"; + } + + var output = option.Arguments.OfType().Single(); + return $"{optionPrefix}{output.Text.Trim('"')}"; + } + + private static string MapArgument(IArgument argument) + { + if (argument is MapStreamArgument map) + { + return map.Text.Replace("-map ", "select=\\'") + "\\'"; + } + else if (argument is BitStreamFilterArgument bitstreamFilter) + { + return bitstreamFilter.Text.Replace("-bsf:", "bsfs/").Replace(' ', '='); + } + + return argument.Text.TrimStart('-').Replace(' ', '='); + } + } +} diff --git a/FFMpegCore/FFMpeg/FFMpegArguments.cs b/FFMpegCore/FFMpeg/FFMpegArguments.cs index 1819cff..cfc6d9d 100644 --- a/FFMpegCore/FFMpeg/FFMpegArguments.cs +++ b/FFMpegCore/FFMpeg/FFMpegArguments.cs @@ -71,6 +71,21 @@ private FFMpegArgumentProcessor ToProcessor(IOutputArgument argument, Action addOutputs, Action? addArguments = null) + { + var outputs = new FFMpegMultiOutputOptions(); + addOutputs(outputs); + return ToProcessor(new OutputTeeArgument(outputs), addArguments); + } + + public FFMpegArgumentProcessor MultiOutput(Action addOutputs) + { + var args = new FFMpegMultiOutputOptions(); + addOutputs(args); + Arguments.AddRange(args.Arguments); + return new FFMpegArgumentProcessor(this); + } + internal void Pre() { foreach (var argument in Arguments.OfType()) diff --git a/FFMpegCore/FFMpeg/FFMpegMultiOutputOptions.cs b/FFMpegCore/FFMpeg/FFMpegMultiOutputOptions.cs new file mode 100644 index 0000000..594413b --- /dev/null +++ b/FFMpegCore/FFMpeg/FFMpegMultiOutputOptions.cs @@ -0,0 +1,29 @@ +using FFMpegCore.Arguments; +using FFMpegCore.Pipes; + +namespace FFMpegCore +{ + public class FFMpegMultiOutputOptions + { + internal readonly List Outputs = new(); + + public IEnumerable Arguments => Outputs.SelectMany(o => o.Arguments); + + public FFMpegMultiOutputOptions OutputToFile(string file, bool overwrite = true, Action? addArguments = null) => AddOutput(new OutputArgument(file, overwrite), addArguments); + + public FFMpegMultiOutputOptions OutputToUrl(string uri, Action? addArguments = null) => AddOutput(new OutputUrlArgument(uri), addArguments); + + public FFMpegMultiOutputOptions OutputToUrl(Uri uri, Action? addArguments = null) => AddOutput(new OutputUrlArgument(uri.ToString()), addArguments); + + public FFMpegMultiOutputOptions OutputToPipe(IPipeSink reader, Action? addArguments = null) => AddOutput(new OutputPipeArgument(reader), addArguments); + + public FFMpegMultiOutputOptions AddOutput(IOutputArgument argument, Action? addArguments) + { + var args = new FFMpegArgumentOptions(); + addArguments?.Invoke(args); + args.Arguments.Add(argument); + Outputs.Add(args); + return this; + } + } +}