mirror of
https://github.com/rosenbjerg/FFMpegCore.git
synced 2024-11-10 08:34:12 +01:00
parent
74c1222b9c
commit
2c2ceacb41
10 changed files with 389 additions and 38 deletions
89
FFMpegCore/Extend/BitmapVideoFrameWrapper.cs
Normal file
89
FFMpegCore/Extend/BitmapVideoFrameWrapper.cs
Normal file
|
@ -0,0 +1,89 @@
|
|||
using FFMpegCore.FFMPEG.Pipes;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Imaging;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.Extend
|
||||
{
|
||||
public class BitmapVideoFrameWrapper : IVideoFrame, IDisposable
|
||||
{
|
||||
public int Width => Source.Width;
|
||||
|
||||
public int Height => Source.Height;
|
||||
|
||||
public string Format { get; private set; }
|
||||
|
||||
public Bitmap Source { get; private set; }
|
||||
|
||||
public BitmapVideoFrameWrapper(Bitmap bitmap)
|
||||
{
|
||||
Source = bitmap ?? throw new ArgumentNullException(nameof(bitmap));
|
||||
Format = ConvertStreamFormat(bitmap.PixelFormat);
|
||||
}
|
||||
|
||||
public void Serialize(IInputPipe pipe)
|
||||
{
|
||||
var data = Source.LockBits(new Rectangle(0, 0, Width, Height), ImageLockMode.ReadOnly, Source.PixelFormat);
|
||||
|
||||
try
|
||||
{
|
||||
var buffer = new byte[data.Stride * data.Height];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, buffer.Length);
|
||||
pipe.Write(buffer, 0, buffer.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Source.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SerializeAsync(IInputPipe pipe)
|
||||
{
|
||||
var data = Source.LockBits(new Rectangle(0, 0, Width, Height), ImageLockMode.ReadOnly, Source.PixelFormat);
|
||||
|
||||
try
|
||||
{
|
||||
var buffer = new byte[data.Stride * data.Height];
|
||||
Marshal.Copy(data.Scan0, buffer, 0, buffer.Length);
|
||||
await pipe.WriteAsync(buffer, 0, buffer.Length);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Source.UnlockBits(data);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Source.Dispose();
|
||||
}
|
||||
|
||||
private static string ConvertStreamFormat(PixelFormat fmt)
|
||||
{
|
||||
switch (fmt)
|
||||
{
|
||||
case PixelFormat.Format16bppGrayScale:
|
||||
return "gray16le";
|
||||
case PixelFormat.Format16bppRgb565:
|
||||
return "bgr565le";
|
||||
case PixelFormat.Format24bppRgb:
|
||||
return "rgb24";
|
||||
case PixelFormat.Format32bppArgb:
|
||||
return "rgba";
|
||||
case PixelFormat.Format32bppPArgb:
|
||||
//This is not really same as argb32
|
||||
return "argb";
|
||||
case PixelFormat.Format32bppRgb:
|
||||
return "rgba";
|
||||
case PixelFormat.Format48bppRgb:
|
||||
return "rgb48le";
|
||||
default:
|
||||
throw new NotSupportedException($"Not supported pixel format {fmt}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -15,7 +15,7 @@ public ArgumentContainer(params Argument[] arguments)
|
|||
{
|
||||
_args = new Dictionary<Type, Argument>();
|
||||
|
||||
foreach(var argument in arguments)
|
||||
foreach (var argument in arguments)
|
||||
{
|
||||
Add(argument);
|
||||
}
|
||||
|
@ -28,7 +28,7 @@ public bool TryGetArgument<T>(out T output)
|
|||
{
|
||||
if (_args.TryGetValue(typeof(T), out var arg))
|
||||
{
|
||||
output = (T) arg;
|
||||
output = (T)arg;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -90,7 +90,7 @@ public bool Contains(KeyValuePair<Type, Argument> item)
|
|||
/// <param name="value">Argument that should be added to collection</param>
|
||||
public void Add(params Argument[] values)
|
||||
{
|
||||
foreach(var value in values)
|
||||
foreach (var value in values)
|
||||
{
|
||||
_args.Add(value.GetType(), value);
|
||||
}
|
||||
|
@ -102,8 +102,9 @@ public void Add(params Argument[] values)
|
|||
/// <returns></returns>
|
||||
public bool ContainsInputOutput()
|
||||
{
|
||||
return ((ContainsKey(typeof(InputArgument)) && !ContainsKey(typeof(ConcatArgument))) ||
|
||||
(!ContainsKey(typeof(InputArgument)) && ContainsKey(typeof(ConcatArgument))))
|
||||
return ((ContainsKey(typeof(InputArgument)) && !ContainsKey(typeof(ConcatArgument)) && !ContainsKey(typeof(InputPipeArgument))) ||
|
||||
(!ContainsKey(typeof(InputArgument)) && ContainsKey(typeof(ConcatArgument)) && !ContainsKey(typeof(InputPipeArgument))) ||
|
||||
(!ContainsKey(typeof(InputArgument)) && !ContainsKey(typeof(ConcatArgument)) && ContainsKey(typeof(InputPipeArgument))))
|
||||
&& ContainsKey(typeof(OutputArgument));
|
||||
}
|
||||
|
||||
|
|
74
FFMpegCore/FFMPEG/Argument/Atoms/InputPipeArgument.cs
Normal file
74
FFMpegCore/FFMPEG/Argument/Atoms/InputPipeArgument.cs
Normal file
|
@ -0,0 +1,74 @@
|
|||
using FFMpegCore.FFMPEG.Pipes;
|
||||
using Instances;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.FFMPEG.Argument
|
||||
{
|
||||
public class InputPipeArgument : Argument, IInputPipe
|
||||
{
|
||||
public string PipeName { get; private set; }
|
||||
public IPipeSource Source { get; private set; }
|
||||
|
||||
private NamedPipeServerStream pipe;
|
||||
|
||||
public InputPipeArgument(IPipeSource source)
|
||||
{
|
||||
Source = source;
|
||||
PipeName = "FFMpegCore_Pipe_" + Guid.NewGuid();
|
||||
}
|
||||
|
||||
public void OpenPipe()
|
||||
{
|
||||
if (pipe != null)
|
||||
throw new InvalidOperationException("Pipe already has been opened");
|
||||
|
||||
pipe = new NamedPipeServerStream(PipeName);
|
||||
}
|
||||
|
||||
public void ClosePipe()
|
||||
{
|
||||
pipe?.Dispose();
|
||||
pipe = null;
|
||||
}
|
||||
|
||||
public void Write(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if(pipe == null)
|
||||
throw new InvalidOperationException("Pipe shouled be opened before");
|
||||
|
||||
pipe.Write(buffer, offset, count);
|
||||
}
|
||||
|
||||
public Task WriteAsync(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (pipe == null)
|
||||
throw new InvalidOperationException("Pipe shouled be opened before");
|
||||
|
||||
return pipe.WriteAsync(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override string GetStringValue()
|
||||
{
|
||||
return $"-y {Source.GetFormat()} -i \\\\.\\pipe\\{PipeName}";
|
||||
}
|
||||
|
||||
public void FlushPipe()
|
||||
{
|
||||
pipe.WaitForConnection();
|
||||
Source.FlushData(this);
|
||||
}
|
||||
|
||||
|
||||
public async Task FlushPipeAsync()
|
||||
{
|
||||
await pipe.WaitForConnectionAsync();
|
||||
await Source.FlushDataAsync(this);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -65,16 +65,16 @@ public Bitmap Snapshot(VideoInfo source, FileInfo output, Size? size = null, Tim
|
|||
{
|
||||
if (size.Value.Width == 0)
|
||||
{
|
||||
var ratio = source.Width / (double) size.Value.Width;
|
||||
var ratio = source.Width / (double)size.Value.Width;
|
||||
|
||||
size = new Size((int) (source.Width * ratio), (int) (source.Height * ratio));
|
||||
size = new Size((int)(source.Width * ratio), (int)(source.Height * ratio));
|
||||
}
|
||||
|
||||
if (size.Value.Height == 0)
|
||||
{
|
||||
var ratio = source.Height / (double) size.Value.Height;
|
||||
var ratio = source.Height / (double)size.Value.Height;
|
||||
|
||||
size = new Size((int) (source.Width * ratio), (int) (source.Height * ratio));
|
||||
size = new Size((int)(source.Width * ratio), (int)(source.Height * ratio));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -96,7 +96,7 @@ public Bitmap Snapshot(VideoInfo source, FileInfo output, Size? size = null, Tim
|
|||
output.Refresh();
|
||||
|
||||
Bitmap result;
|
||||
using (var bmp = (Bitmap) Image.FromFile(output.FullName))
|
||||
using (var bmp = (Bitmap)Image.FromFile(output.FullName))
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
bmp.Save(ms, ImageFormat.Png);
|
||||
|
@ -135,8 +135,8 @@ public VideoInfo Convert(
|
|||
FFMpegHelper.ExtensionExceptionCheck(output, FileExtension.ForType(type));
|
||||
FFMpegHelper.ConversionSizeExceptionCheck(source);
|
||||
|
||||
var scale = VideoSize.Original == size ? 1 : (double) source.Height / (int) size;
|
||||
var outputSize = new Size((int) (source.Width / scale), (int) (source.Height / scale));
|
||||
var scale = VideoSize.Original == size ? 1 : (double)source.Height / (int)size;
|
||||
var outputSize = new Size((int)(source.Width / scale), (int)(source.Height / scale));
|
||||
|
||||
if (outputSize.Width % 2 != 0)
|
||||
outputSize.Width += 1;
|
||||
|
@ -384,6 +384,7 @@ public VideoInfo ReplaceAudio(VideoInfo source, FileInfo audio, FileInfo output,
|
|||
public VideoInfo Convert(ArgumentContainer arguments)
|
||||
{
|
||||
var (sources, output) = GetInputOutput(arguments);
|
||||
if (sources != null)
|
||||
_totalTime = TimeSpan.FromSeconds(sources.Sum(source => source.Duration.TotalSeconds));
|
||||
|
||||
if (!RunProcess(arguments, output))
|
||||
|
@ -395,6 +396,7 @@ public VideoInfo Convert(ArgumentContainer arguments)
|
|||
public async Task<VideoInfo> ConvertAsync(ArgumentContainer arguments)
|
||||
{
|
||||
var (sources, output) = GetInputOutput(arguments);
|
||||
if (sources != null)
|
||||
_totalTime = TimeSpan.FromSeconds(sources.Sum(source => source.Duration.TotalSeconds));
|
||||
|
||||
if (!await RunProcessAsync(arguments, output))
|
||||
|
@ -406,12 +408,14 @@ public async Task<VideoInfo> ConvertAsync(ArgumentContainer arguments)
|
|||
|
||||
private static (VideoInfo[] Input, FileInfo Output) GetInputOutput(ArgumentContainer arguments)
|
||||
{
|
||||
var output = ((OutputArgument) arguments[typeof(OutputArgument)]).GetAsFileInfo();
|
||||
var output = ((OutputArgument)arguments[typeof(OutputArgument)]).GetAsFileInfo();
|
||||
VideoInfo[] sources;
|
||||
if (arguments.TryGetArgument<InputArgument>(out var input))
|
||||
sources = input.GetAsVideoInfo();
|
||||
else if (arguments.TryGetArgument<ConcatArgument>(out var concat))
|
||||
sources = concat.GetAsVideoInfo();
|
||||
else if (arguments.TryGetArgument<InputPipeArgument>(out var pipe))
|
||||
sources = null;
|
||||
else
|
||||
throw new FFMpegException(FFMpegExceptionType.Operation, "No input or concat argument found");
|
||||
return (sources, output);
|
||||
|
@ -442,23 +446,68 @@ private bool RunProcess(ArgumentContainer container, FileInfo output)
|
|||
{
|
||||
_instance?.Dispose();
|
||||
var arguments = ArgumentBuilder.BuildArguments(container);
|
||||
var exitCode = -1;
|
||||
|
||||
if (container.TryGetArgument<InputPipeArgument>(out var inputPipeArgument))
|
||||
{
|
||||
inputPipeArgument.OpenPipe();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_instance = new Instance(_ffmpegPath, arguments);
|
||||
_instance.DataReceived += OutputData;
|
||||
var exitCode = _instance.BlockUntilFinished();
|
||||
|
||||
if (inputPipeArgument != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var task = _instance.FinishedRunning();
|
||||
inputPipeArgument.FlushPipe();
|
||||
inputPipeArgument.ClosePipe();
|
||||
task.Wait();
|
||||
exitCode = task.Result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new FFMpegException(FFMpegExceptionType.Process, string.Join("\n", _instance.ErrorData), ex);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
exitCode = _instance.BlockUntilFinished();
|
||||
}
|
||||
|
||||
if (!File.Exists(output.FullName) || new FileInfo(output.FullName).Length == 0)
|
||||
throw new FFMpegException(FFMpegExceptionType.Process, string.Join("\n", _instance.ErrorData));
|
||||
|
||||
return exitCode == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inputPipeArgument != null)
|
||||
inputPipeArgument.ClosePipe();
|
||||
}
|
||||
}
|
||||
private async Task<bool> RunProcessAsync(ArgumentContainer container, FileInfo output)
|
||||
{
|
||||
_instance?.Dispose();
|
||||
var arguments = ArgumentBuilder.BuildArguments(container);
|
||||
|
||||
if (container.TryGetArgument<InputPipeArgument>(out var inputPipeArgument))
|
||||
{
|
||||
inputPipeArgument.OpenPipe();
|
||||
}
|
||||
try
|
||||
{
|
||||
|
||||
_instance = new Instance(_ffmpegPath, arguments);
|
||||
_instance.DataReceived += OutputData;
|
||||
|
||||
if (inputPipeArgument != null)
|
||||
{
|
||||
await inputPipeArgument.FlushPipeAsync();
|
||||
}
|
||||
var exitCode = await _instance.FinishedRunning();
|
||||
|
||||
if (!File.Exists(output.FullName) || new FileInfo(output.FullName).Length == 0)
|
||||
|
@ -466,6 +515,14 @@ private async Task<bool> RunProcessAsync(ArgumentContainer container, FileInfo o
|
|||
|
||||
return exitCode == 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (inputPipeArgument != null)
|
||||
{
|
||||
inputPipeArgument.ClosePipe();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Cleanup(IEnumerable<string> pathList)
|
||||
{
|
||||
|
|
|
@ -47,7 +47,7 @@ public Task<VideoInfo> ParseVideoInfoAsync(string source)
|
|||
/// <returns>A video info object containing all details necessary.</returns>
|
||||
public VideoInfo ParseVideoInfo(VideoInfo info)
|
||||
{
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(info)) {DataBufferCapacity = _outputCapacity};
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(info.FullName)) {DataBufferCapacity = _outputCapacity};
|
||||
instance.BlockUntilFinished();
|
||||
var output = string.Join("", instance.OutputData);
|
||||
return ParseVideoInfoInternal(info, output);
|
||||
|
@ -59,14 +59,14 @@ public VideoInfo ParseVideoInfo(VideoInfo info)
|
|||
/// <returns>A video info object containing all details necessary.</returns>
|
||||
public async Task<VideoInfo> ParseVideoInfoAsync(VideoInfo info)
|
||||
{
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(info)) {DataBufferCapacity = _outputCapacity};
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(info.FullName)) {DataBufferCapacity = _outputCapacity};
|
||||
await instance.FinishedRunning();
|
||||
var output = string.Join("", instance.OutputData);
|
||||
return ParseVideoInfoInternal(info, output);
|
||||
}
|
||||
|
||||
private static string BuildFFProbeArguments(VideoInfo info) =>
|
||||
$"-v quiet -print_format json -show_streams \"{info.FullName}\"";
|
||||
private static string BuildFFProbeArguments(string fullPath) =>
|
||||
$"-v quiet -print_format json -show_streams \"{fullPath}\"";
|
||||
|
||||
private VideoInfo ParseVideoInfoInternal(VideoInfo info, string probeOutput)
|
||||
{
|
||||
|
@ -133,5 +133,21 @@ private VideoInfo ParseVideoInfoInternal(VideoInfo info, string probeOutput)
|
|||
|
||||
return info;
|
||||
}
|
||||
|
||||
internal FFMpegStreamMetadata GetMetadata(string path)
|
||||
{
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(path)) { DataBufferCapacity = _outputCapacity };
|
||||
instance.BlockUntilFinished();
|
||||
var output = string.Join("", instance.OutputData);
|
||||
return JsonConvert.DeserializeObject<FFMpegStreamMetadata>(output);
|
||||
}
|
||||
|
||||
internal async Task<FFMpegStreamMetadata> GetMetadataAsync(string path)
|
||||
{
|
||||
var instance = new Instance(_ffprobePath, BuildFFProbeArguments(path)) { DataBufferCapacity = _outputCapacity };
|
||||
await instance.FinishedRunning();
|
||||
var output = string.Join("", instance.OutputData);
|
||||
return JsonConvert.DeserializeObject<FFMpegStreamMetadata>(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
13
FFMpegCore/FFMPEG/Pipes/IInputPipe.cs
Normal file
13
FFMpegCore/FFMPEG/Pipes/IInputPipe.cs
Normal file
|
@ -0,0 +1,13 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.FFMPEG.Pipes
|
||||
{
|
||||
public interface IInputPipe
|
||||
{
|
||||
void Write(byte[] buffer, int offset, int count);
|
||||
Task WriteAsync(byte[] buffer, int offset, int count);
|
||||
}
|
||||
}
|
15
FFMpegCore/FFMPEG/Pipes/IPipeSource.cs
Normal file
15
FFMpegCore/FFMPEG/Pipes/IPipeSource.cs
Normal file
|
@ -0,0 +1,15 @@
|
|||
using FFMpegCore.FFMPEG.Argument;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.FFMPEG.Pipes
|
||||
{
|
||||
public interface IPipeSource
|
||||
{
|
||||
string GetFormat();
|
||||
void FlushData(IInputPipe pipe);
|
||||
Task FlushDataAsync(IInputPipe pipe);
|
||||
}
|
||||
}
|
17
FFMpegCore/FFMPEG/Pipes/IVideoFrame.cs
Normal file
17
FFMpegCore/FFMPEG/Pipes/IVideoFrame.cs
Normal file
|
@ -0,0 +1,17 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.FFMPEG.Pipes
|
||||
{
|
||||
public interface IVideoFrame
|
||||
{
|
||||
int Width { get; }
|
||||
int Height { get; }
|
||||
string Format { get; }
|
||||
|
||||
void Serialize(IInputPipe pipe);
|
||||
Task SerializeAsync(IInputPipe pipe);
|
||||
}
|
||||
}
|
67
FFMpegCore/FFMPEG/Pipes/RawVideoPipeSource.cs
Normal file
67
FFMpegCore/FFMPEG/Pipes/RawVideoPipeSource.cs
Normal file
|
@ -0,0 +1,67 @@
|
|||
using FFMpegCore.FFMPEG.Argument;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FFMpegCore.FFMPEG.Pipes
|
||||
{
|
||||
public class RawVideoPipeSource : IPipeSource
|
||||
{
|
||||
public string StreamFormat { get; set; }
|
||||
public int Width { get; set; }
|
||||
public int Height { get; set; }
|
||||
public int FrameRate { get; set; } = 25;
|
||||
private IEnumerator<IVideoFrame> framesEnumerator;
|
||||
|
||||
public RawVideoPipeSource(IEnumerator<IVideoFrame> framesEnumerator)
|
||||
{
|
||||
this.framesEnumerator = framesEnumerator;
|
||||
}
|
||||
|
||||
public RawVideoPipeSource(IEnumerable<IVideoFrame> framesEnumerator) : this(framesEnumerator.GetEnumerator()) { }
|
||||
|
||||
public string GetFormat()
|
||||
{
|
||||
//see input format references https://lists.ffmpeg.org/pipermail/ffmpeg-user/2012-July/007742.html
|
||||
if (framesEnumerator.Current == null)
|
||||
{
|
||||
if (!framesEnumerator.MoveNext())
|
||||
throw new InvalidOperationException("Enumerator is empty, unable to get frame");
|
||||
|
||||
StreamFormat = framesEnumerator.Current.Format;
|
||||
Width = framesEnumerator.Current.Width;
|
||||
Height = framesEnumerator.Current.Height;
|
||||
}
|
||||
|
||||
return $"-f rawvideo -r {FrameRate} -pix_fmt {StreamFormat} -s {Width}x{Height}";
|
||||
}
|
||||
|
||||
public void FlushData(IInputPipe pipe)
|
||||
{
|
||||
if (framesEnumerator.Current != null)
|
||||
{
|
||||
framesEnumerator.Current.Serialize(pipe);
|
||||
}
|
||||
|
||||
while (framesEnumerator.MoveNext())
|
||||
{
|
||||
framesEnumerator.Current.Serialize(pipe);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task FlushDataAsync(IInputPipe pipe)
|
||||
{
|
||||
if (framesEnumerator.Current != null)
|
||||
{
|
||||
await framesEnumerator.Current.SerializeAsync(pipe);
|
||||
}
|
||||
|
||||
while (framesEnumerator.MoveNext())
|
||||
{
|
||||
await framesEnumerator.Current.SerializeAsync(pipe);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
using FFMpegCore.FFMPEG;
|
||||
using FFMpegCore.FFMPEG.Argument;
|
||||
using FFMpegCore.FFMPEG.Pipes;
|
||||
using System;
|
||||
using System.IO;
|
||||
|
||||
|
|
Loading…
Reference in a new issue