diff --git a/README.md b/README.md index 6def826..0d734ad 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,11 @@ # SharpGrabber SharpGrabber - -A **.NET Standard** library for grabbing information and -downloading from top media providers such as **YouTube**, **Instagram** etc. -## Features -- Grabs useful information about media such as length, title, author and many more. -- Deciphers secure *YouTube* videos optionally. -- Extracts direct links to all available qualities. -- Extracts images and thumbnails. -- Supports *asynchronous* operations. +This project consists of several connected sub-projects: +- `SharpGrabber` is a *.NET Standard* library for crawling into top media provider websites such as **YouTube**, **Instagram** etc. in order to grab information and return direct links of the audio/video files. +- `SharpGrabber.Converter` is a *.NET Standard* library based on `ffmpeg` to join audio and video streams. This is particularly useful when grabbing high quality *YouTube* media that might be separated into audio and video files. +- `SharpGrabber.Desktop` A cross-platform desktop application +which utilizes both mentioned libraries to expose their functionality for desktop end-users. ### Supported Providers The following providers are currently supported with the option @@ -18,13 +14,25 @@ to easily add more or even override part of grabbing algorithm with your own cod - YouTube - Instagram -## Installation -Install *SharpGrabber* automatically using NuGet package manager. +## Features +#### SharpGrabber Library +- Grabs useful information about media such as length, title, author and many more. +- Deciphers secure *YouTube* videos optionally. +- Extracts direct links to all available qualities. +- Extracts images and thumbnails. +- Supports *asynchronous* operations. + +#### SharpGrabber.Desktop Application +- Displays information obtained by the `SharpGrabber` library and downloads the resolved direct links. +- Uses `SharpGrabber.Converter` to merge YouTube separated audio and video streams into complete media files. + +## SharpGrabber Installation +Include *SharpGrabber* library in your own .NET projects. ### Install via NuGet Install-Package DotNetTools.SharpGrabber -Version 1.0.0 -## Usage Example +## SharpGrabber Usage Example ### Download specifically from a provider @@ -38,11 +46,19 @@ Install *SharpGrabber* automatically using NuGet package manager. var result = await grabber.GrabAsync(new Uri("")); IList grabbedResources = result.Resources; +## SharpGrabber.Desktop +Requirements of the cross-platform desktop application to run and operate correctly: + - .NET Core 2.1 or higher (.NET Framework 4.6.1 or higher) + - Shared libraries of *ffmpeg* copied into `ffmpeg` directory alongside app executable files for media conversion support. + - On Windows, you may download the latest Zeranoe ffmpeg build. + +SharpGrabber.Desktop application + ## Roadmap This project is very much in progress and the following features are top priority: -- Conversion support (especially useful for high quality YouTube videos) -- .NET Core demo app +- Support for Android +- Accelerate downloads in the desktop app (like a download manager) - Support for more media providers ## Support diff --git a/SharpGrabber.sln b/SharpGrabber.sln index 1018ec1..39c31ac 100644 --- a/SharpGrabber.sln +++ b/SharpGrabber.sln @@ -1,10 +1,15 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 + +Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 16 VisualStudioVersion = 16.0.29418.71 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNetTools.SharpGrabber", "src\DotNetTools.SharpGrabber\DotNetTools.SharpGrabber.csproj", "{F2CB23B0-E878-4CDE-9F29-C3B8E915125B}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpGrabber", "src\SharpGrabber\SharpGrabber.csproj", "{F2CB23B0-E878-4CDE-9F29-C3B8E915125B}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DotNetTools.SharpGrabber.Tests", "tests\DotNetTools.SharpGrabber.Tests\DotNetTools.SharpGrabber.Tests.csproj", "{B597E685-3509-435E-B11A-BC5A151051CE}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpGrabber.Tests", "tests\DotNetTools.SharpGrabber.Tests\SharpGrabber.Tests.csproj", "{B597E685-3509-435E-B11A-BC5A151051CE}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpGrabber.Converter", "src\SharpGrabber.Converter\SharpGrabber.Converter.csproj", "{C0A29188-D1FA-4F00-A77A-D1B17FD992FF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "SharpGrabber.Desktop", "src\SharpGrabber.Desktop\SharpGrabber.Desktop.csproj", "{F57085EC-1886-4671-A8B2-7EAAA4EE1871}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -20,6 +25,14 @@ Global {B597E685-3509-435E-B11A-BC5A151051CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {B597E685-3509-435E-B11A-BC5A151051CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {B597E685-3509-435E-B11A-BC5A151051CE}.Release|Any CPU.Build.0 = Release|Any CPU + {C0A29188-D1FA-4F00-A77A-D1B17FD992FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0A29188-D1FA-4F00-A77A-D1B17FD992FF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0A29188-D1FA-4F00-A77A-D1B17FD992FF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0A29188-D1FA-4F00-A77A-D1B17FD992FF}.Release|Any CPU.Build.0 = Release|Any CPU + {F57085EC-1886-4671-A8B2-7EAAA4EE1871}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F57085EC-1886-4671-A8B2-7EAAA4EE1871}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F57085EC-1886-4671-A8B2-7EAAA4EE1871}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F57085EC-1886-4671-A8B2-7EAAA4EE1871}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/assets/SharpGrabberDesktop-ScreenShot-1.png b/assets/SharpGrabberDesktop-ScreenShot-1.png new file mode 100644 index 0000000..6191651 Binary files /dev/null and b/assets/SharpGrabberDesktop-ScreenShot-1.png differ diff --git a/src/SharpGrabber.Converter/FFMpegHelper.cs b/src/SharpGrabber.Converter/FFMpegHelper.cs new file mode 100644 index 0000000..b2d2060 --- /dev/null +++ b/src/SharpGrabber.Converter/FFMpegHelper.cs @@ -0,0 +1,27 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + internal static class FFMpegHelper + { + public static unsafe string av_strerror(int error) + { + var bufferSize = 1024; + var buffer = stackalloc byte[bufferSize]; + ffmpeg.av_strerror(error, buffer, (ulong)bufferSize); + var message = Marshal.PtrToStringAnsi((IntPtr)buffer); + return message; + } + + public static int ThrowOnError(this int result) + { + if (result < 0) + throw new ApplicationException(av_strerror(result)); + return result; + } + } +} diff --git a/src/SharpGrabber.Converter/IOContext.cs b/src/SharpGrabber.Converter/IOContext.cs new file mode 100644 index 0000000..17bb618 --- /dev/null +++ b/src/SharpGrabber.Converter/IOContext.cs @@ -0,0 +1,51 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + unsafe sealed class IOContext : IDisposable + { + #region Fields + private AVIOContext* _ioContext; + private bool usedAvioOpen; + #endregion + + #region Properties + public AVIOContext* Pointer => _ioContext; + #endregion + + #region Constructor + public IOContext(AVIOContext* ioContext) + { + _ioContext = ioContext; + } + + public IOContext(string path, int flags) + { + usedAvioOpen = true; + AVIOContext* ioContext = null; + ffmpeg.avio_open2(&ioContext, path, flags, null, null).ThrowOnError(); + _ioContext = ioContext; + } + + public IOContext(Uri uri, int flags) : this(uri.IsFile ? uri.LocalPath : uri.ToString(), flags) { } + #endregion + + #region Methods + public void Dispose() + { + if (_ioContext != null) + { + var ioContext = _ioContext; + if (usedAvioOpen) + ffmpeg.avio_close(ioContext); + else + ffmpeg.avio_context_free(&ioContext); + _ioContext = null; + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaBuilder.cs b/src/SharpGrabber.Converter/MediaBuilder.cs new file mode 100644 index 0000000..9e4b374 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaBuilder.cs @@ -0,0 +1,272 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; + +namespace DotNetTools.SharpGrabber.Converter +{ + /// + /// Can mux multiple media streams together into a container. + /// + public unsafe sealed class MediaBuilder + { + #region Fields + private readonly Dictionary _sources = new Dictionary(); + #endregion + + #region Properties + /// + /// Path to the output file + /// + public string OutputPath { get; set; } + + public string OutputShortName { get; set; } = "webm"; + + public string OutputMimeType { get; set; } = "video/webm"; + + public AVHWDeviceType HardwareDevice { get; set; } + + public AVCodecID? TargetAudioCodec { get; set; } = null; + + public AVCodecID? TargetVideoCodec { get; set; } = null; + #endregion + + #region Constructor + public MediaBuilder() { } + + public MediaBuilder(string outputPath) + { + OutputPath = outputPath; + } + #endregion + + #region Methods + public void AddStreamSource(Uri path, MediaStreamType streamType) + { + _sources.Add(streamType, new MediaStreamSource(path, streamType)); + } + + public void AddStreamSource(string path, MediaStreamType streamType) => AddStreamSource(new Uri($"file://{path.Replace('\\', '/')}"), streamType); + + public void AutoSelectHardwareDevice() + { + var types = MediaHelper.GetHardwareDeviceTypes(); + if (types.Length > 0) + HardwareDevice = types[0]; + } + #endregion + + #region Internal Methods + private void ValidateArguments() + { + if (string.IsNullOrEmpty(OutputPath)) + throw new ArgumentNullException(nameof(OutputPath)); + if (_sources.Count < 1 || _sources.Count > 2) + throw new ArgumentException("Can only merge one or two source streams."); + } + #endregion + + #region Build Methods + private Dictionary MakeDecoders() + { + var decoders = new Dictionary(); + foreach (var source in _sources.Values) + { + var decoder = new MediaDecoder(source.Path, HardwareDevice); + switch (source.StreamType) + { + case MediaStreamType.Audio: + decoder.SelectStream(AVMediaType.AVMEDIA_TYPE_AUDIO); + break; + + case MediaStreamType.Video: + decoder.SelectStream(AVMediaType.AVMEDIA_TYPE_VIDEO); + break; + + default: + throw new NotSupportedException($"Media stream type of {source.StreamType} is not supported."); + } + decoders.Add(source.StreamType, decoder); + } + return decoders; + } + + public void Build() + { + var streams = new AVStream*[2]; + var stream_dic = new Dictionary(); + + ValidateArguments(); + + // create decoders + var decoders = MakeDecoders(); + try + { + // open output iocontext + using (var output = new IOContext(OutputPath, ffmpeg.AVIO_FLAG_WRITE)) + // open muxer + using (var muxer = new MediaMuxer(output, OutputShortName, OutputMimeType)) + { + // add streams + var index = 0; + foreach (var decoderPair in decoders) + { + var decoder = decoderPair.Value; + var targetCodec = decoder.CodecId; + var decoderCodec = decoder.CodecContext; + + switch (decoderPair.Key) + { + case MediaStreamType.Audio: + if (TargetAudioCodec != null) + targetCodec = TargetAudioCodec.Value; + break; + + case MediaStreamType.Video: + if (TargetVideoCodec != null) + targetCodec = TargetVideoCodec.Value; + break; + } + + var encoder = ffmpeg.avcodec_find_encoder(targetCodec); + var outStream = muxer.AddStream(encoder); + var param = outStream->codecpar; + streams[index] = outStream; + stream_dic.Add(decoderPair.Key, index++); + + if (decoder.CodecId == targetCodec) + { + // converting to the same codec + ffmpeg.avcodec_parameters_from_context(param, decoder.GetStream()->codec).ThrowOnError(); + } + else + { + // converting to another codec + switch (decoderPair.Key) + { + case MediaStreamType.Audio: + param->codec_id = targetCodec; + param->codec_type = AVMediaType.AVMEDIA_TYPE_AUDIO; + param->sample_rate = decoderCodec->sample_rate; + param->channels = decoderCodec->channels; + param->channel_layout = decoderCodec->channel_layout; + outStream->time_base = decoderCodec->time_base; + break; + + case MediaStreamType.Video: + throw new NotSupportedException(); + } + } + outStream->codecpar->codec_tag = 0; + } + + // write headers + muxer.WriteHeader(); + + // write packets + long audio_dts = ffmpeg.AV_NOPTS_VALUE, video_dts = ffmpeg.AV_NOPTS_VALUE; + long audio_pts = 0, video_pts = 0; + var audio_stream = decoders.Where(pair => pair.Key == MediaStreamType.Audio).First(); + var video_stream = decoders.Where(pair => pair.Key == MediaStreamType.Video).First(); + bool any_audio = true, any_video = true; + + while (true) + { + bool anyPacket = false; + + while (any_audio || any_video) + { + KeyValuePair decoderPair; + + // choose a decoder pair + if (!any_audio) + decoderPair = video_stream; + else if (!any_video) + decoderPair = audio_stream; + else + { + // choose between audio and video + decoderPair = video_dts < audio_dts ? video_stream : audio_stream; + } + + // decoder is chosen now, + // let's read and encode + var decoder = decoderPair.Value; + var stream_index = stream_dic[decoderPair.Key]; + var outputStream = streams[stream_index]; + + if (decoder.CodecId == outputStream->codec->codec_id) + { + // simply copy to target stream + using (var inputFrame = decoder.ReadPacket()) + { + if (inputFrame == null) + { + if (decoder == audio_stream.Value) + any_audio = false; + else + any_video = false; + continue; + } + var pck = inputFrame.Pointer; + ffmpeg.av_packet_rescale_ts(pck, decoder.TimeBase, outputStream->time_base); + pck->stream_index = stream_index; + + long* last_dts, last_pts; + switch (decoder.CodecContext->codec_type) + { + case AVMediaType.AVMEDIA_TYPE_AUDIO: + last_dts = &audio_dts; + last_pts = &audio_pts; + break; + + case AVMediaType.AVMEDIA_TYPE_VIDEO: + last_dts = &video_dts; + last_pts = &video_pts; + break; + + default: + throw new NotSupportedException(); + } + + if (pck->dts < (*last_dts + ((muxer.FormatContextPtr->oformat->flags & ffmpeg.AVFMT_TS_NONSTRICT) > 0 ? 0 : 1)) && pck->dts != ffmpeg.AV_NOPTS_VALUE && *last_dts != ffmpeg.AV_NOPTS_VALUE) + { + var next_dts = (*last_dts) + 1; + if (pck->pts >= pck->dts && pck->pts != ffmpeg.AV_NOPTS_VALUE) + pck->pts = Math.Max(pck->pts, next_dts); + + if (pck->pts == ffmpeg.AV_NOPTS_VALUE) + pck->pts = next_dts; + pck->dts = next_dts; + } + (*last_dts) = pck->dts; + + muxer.WritePacket(pck); + anyPacket = true; + } + } + else + throw new NotSupportedException("Format conversion is not supported."); + } + + if (!anyPacket) + break; + } + + // write trailer + muxer.WriteTrailer(); + } + } + finally + { + // dispose decoders + foreach (var decoder in decoders.Values) + decoder.Dispose(); + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaDecoder.cs b/src/SharpGrabber.Converter/MediaDecoder.cs new file mode 100644 index 0000000..f42c166 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaDecoder.cs @@ -0,0 +1,234 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Text; +using FFmpeg.AutoGen; + +namespace DotNetTools.SharpGrabber.Converter +{ + public sealed unsafe class MediaDecoder : IDisposable + { + #region Fields + private AVFormatContext* _avFormatContext; + private AVCodecContext* _avCodecContext; + private int _streamIndex; + #endregion + + #region Properties + public Uri Source { get; } + + public AVHWDeviceType HardwareDevice { get; } + + public AVCodecID CodecId { get; private set; } + + public string CodecName { get; private set; } + + public long BitRate { get; private set; } + + public AVRational FrameRate { get; private set; } + + public AVRational TimeBase { get; private set; } + + public Size FrameSize { get; private set; } + + public int AudioFrameSize { get; private set; } + + public AVPixelFormat PixelFormat { get; private set; } + + public AVCodecContext* CodecContext => _avCodecContext; + #endregion + + #region Constructor + public MediaDecoder(Uri source, AVHWDeviceType hardwareDevice) + { + Source = source; + HardwareDevice = hardwareDevice; + Open(); + } + #endregion + + #region Internal Methods + private void Open() + { + string path = Source.IsFile ? Source.LocalPath : Source.ToString(); + var avFormatContext = _avFormatContext = ffmpeg.avformat_alloc_context(); + ffmpeg.avformat_open_input(&avFormatContext, path, null, null).ThrowOnError(); + ffmpeg.avformat_find_stream_info(avFormatContext, null).ThrowOnError(); + } + + private bool ReadFrame(AVPacket* packet) + { + do + { + var result = ffmpeg.av_read_frame(_avFormatContext, packet); + if (result == ffmpeg.AVERROR_EOF) + return false; + result.ThrowOnError(); + } while (packet->stream_index != _streamIndex); + + return true; + } + #endregion + + #region Methods + public void SelectStream(AVMediaType type) + { + AVCodec* avCodec = null; + _streamIndex = ffmpeg.av_find_best_stream(_avFormatContext, type, -1, -1, &avCodec, 0).ThrowOnError(); + _avCodecContext = ffmpeg.avcodec_alloc_context3(avCodec); + var stream = _avFormatContext->streams[_streamIndex]; + + if (HardwareDevice != AVHWDeviceType.AV_HWDEVICE_TYPE_NONE) + ffmpeg.av_hwdevice_ctx_create(&_avCodecContext->hw_device_ctx, HardwareDevice, null, null, 0).ThrowOnError(); + + ffmpeg.avcodec_parameters_to_context(_avCodecContext, stream->codecpar).ThrowOnError(); + ffmpeg.avcodec_open2(_avCodecContext, avCodec, null).ThrowOnError(); + + CodecId = avCodec->id; + CodecName = ffmpeg.avcodec_get_name(CodecId); + FrameSize = new Size(_avCodecContext->width, _avCodecContext->height); + AudioFrameSize = _avCodecContext->frame_size; ; + PixelFormat = HardwareDevice == AVHWDeviceType.AV_HWDEVICE_TYPE_NONE ? _avCodecContext->pix_fmt : GetHWPixelFormat(HardwareDevice); + BitRate = _avCodecContext->bit_rate; + FrameRate = _avCodecContext->framerate; + TimeBase = stream->time_base; + } + + public AVStream* GetStream() + { + return _avFormatContext->streams[_streamIndex]; + } + + public void Dispose() + { + if (_avFormatContext != null) + { + var avFormatContext = _avFormatContext; + if (Source.IsFile) + ffmpeg.avformat_close_input(&avFormatContext); + else + ffmpeg.avformat_free_context(avFormatContext); + _avFormatContext = null; + } + + if (_avCodecContext != null) + { + var avCodecContext = _avCodecContext; + ffmpeg.avcodec_free_context(&avCodecContext); + _avCodecContext = null; + } + } + + /// + /// Tries to read the next packet. Returns NULL on EOF. + /// + public MediaPacket ReadPacket() + { + var packet = ffmpeg.av_packet_alloc(); + + try + { + ffmpeg.av_init_packet(packet); + + if (!ReadFrame(packet)) + return null; + + return new MediaPacket(packet); + } + catch (Exception) + { + ffmpeg.av_packet_unref(packet); + throw; + } + } + + public MediaFrame ReadFrame(MediaPacket packet) + { + var frame = ffmpeg.av_frame_alloc(); + ffmpeg.avcodec_send_packet(_avCodecContext, packet.Pointer).ThrowOnError(); + + while (true) + { + var result = ffmpeg.avcodec_receive_frame(_avCodecContext, frame).ThrowOnError(); + if (result == ffmpeg.AVERROR(ffmpeg.EAGAIN)) + { + ReadFrame(packet.Pointer); + continue; + } + result.ThrowOnError(); + break; + }; + + // hardware decode + if (HardwareDevice != AVHWDeviceType.AV_HWDEVICE_TYPE_NONE) + { + var hwframe = ffmpeg.av_frame_alloc(); + ffmpeg.av_hwframe_transfer_data(hwframe, frame, 0).ThrowOnError(); + ffmpeg.av_frame_unref(frame); + frame = hwframe; + } + + return new MediaFrame(frame); + } + + public MediaFrame ReadFrame() + { + using (var packet = ReadPacket()) + { + if (packet == null) + return null; + return ReadFrame(packet); + } + } + + public bool ReadFrameAndConvert(Action frameFeed, AVPixelFormat pixelFormat = AVPixelFormat.AV_PIX_FMT_BGR24) + { + using (var frame = ReadFrame()) + { + if (frame == null) + return false; + + using (var conf = new VideoFrameConverter(FrameSize, PixelFormat, FrameSize, pixelFormat)) + { + var convFrame = conf.Convert(*frame.Pointer); + frameFeed.Invoke(convFrame, (IntPtr)convFrame.data[0]); + return true; + } + } + } + #endregion + + #region Static Methods + private static AVPixelFormat GetHWPixelFormat(AVHWDeviceType hWDevice) + { + switch (hWDevice) + { + case AVHWDeviceType.AV_HWDEVICE_TYPE_NONE: + return AVPixelFormat.AV_PIX_FMT_NONE; + case AVHWDeviceType.AV_HWDEVICE_TYPE_VDPAU: + return AVPixelFormat.AV_PIX_FMT_VDPAU; + case AVHWDeviceType.AV_HWDEVICE_TYPE_CUDA: + return AVPixelFormat.AV_PIX_FMT_CUDA; + case AVHWDeviceType.AV_HWDEVICE_TYPE_VAAPI: + return AVPixelFormat.AV_PIX_FMT_VAAPI; + case AVHWDeviceType.AV_HWDEVICE_TYPE_DXVA2: + return AVPixelFormat.AV_PIX_FMT_NV12; + case AVHWDeviceType.AV_HWDEVICE_TYPE_QSV: + return AVPixelFormat.AV_PIX_FMT_QSV; + case AVHWDeviceType.AV_HWDEVICE_TYPE_VIDEOTOOLBOX: + return AVPixelFormat.AV_PIX_FMT_VIDEOTOOLBOX; + case AVHWDeviceType.AV_HWDEVICE_TYPE_D3D11VA: + return AVPixelFormat.AV_PIX_FMT_NV12; + case AVHWDeviceType.AV_HWDEVICE_TYPE_DRM: + return AVPixelFormat.AV_PIX_FMT_DRM_PRIME; + case AVHWDeviceType.AV_HWDEVICE_TYPE_OPENCL: + return AVPixelFormat.AV_PIX_FMT_OPENCL; + case AVHWDeviceType.AV_HWDEVICE_TYPE_MEDIACODEC: + return AVPixelFormat.AV_PIX_FMT_MEDIACODEC; + default: + return AVPixelFormat.AV_PIX_FMT_NONE; + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaFrame.cs b/src/SharpGrabber.Converter/MediaFrame.cs new file mode 100644 index 0000000..4169c12 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaFrame.cs @@ -0,0 +1,36 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + /// + /// Wrapper for . + /// + public sealed unsafe class MediaFrame : IDisposable + { + #region Properties + public AVFrame* Pointer { get; private set; } + #endregion + + #region Constructor + public MediaFrame(AVFrame* frame) + { + Pointer = frame; + } + #endregion + + #region Methods + public void Dispose() + { + if (Pointer != null) + { + var frame = Pointer; + ffmpeg.av_frame_unref(frame); + Pointer = null; + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaHelper.cs b/src/SharpGrabber.Converter/MediaHelper.cs new file mode 100644 index 0000000..f0fcaa8 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaHelper.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using FFmpeg.AutoGen; + +namespace DotNetTools.SharpGrabber.Converter +{ + public static class MediaHelper + { + public static AVHWDeviceType[] GetHardwareDeviceTypes() + { + var set = new HashSet(); + var type = AVHWDeviceType.AV_HWDEVICE_TYPE_NONE; + while ((type = ffmpeg.av_hwdevice_iterate_types(type)) != AVHWDeviceType.AV_HWDEVICE_TYPE_NONE) + set.Add(type); + return set.ToArray(); + } + } +} diff --git a/src/SharpGrabber.Converter/MediaLibrary.cs b/src/SharpGrabber.Converter/MediaLibrary.cs new file mode 100644 index 0000000..9df1be5 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaLibrary.cs @@ -0,0 +1,17 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + public static class MediaLibrary + { + public static void Load(string dir) + { + ffmpeg.RootPath = dir; + } + + public static string FFMpegVersion() => ffmpeg.av_version_info(); + } +} diff --git a/src/SharpGrabber.Converter/MediaMuxer.cs b/src/SharpGrabber.Converter/MediaMuxer.cs new file mode 100644 index 0000000..d8dcc60 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaMuxer.cs @@ -0,0 +1,90 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + sealed unsafe class MediaMuxer : IDisposable + { + #region Fields + private AVFormatContext* _formatContext; + private IOContext _ioContext; + #endregion + + #region Properties + public AVFormatContext* FormatContextPtr => _formatContext; + #endregion + + #region Constructors + public MediaMuxer(IOContext output, string shortName, string mimeType) + { + _ioContext = output; + var formatContext = _formatContext; + var format = ffmpeg.av_guess_format(shortName, null, mimeType); + ffmpeg.avformat_alloc_output_context2(&formatContext, format, null, null).ThrowOnError(); + _formatContext = formatContext; + _formatContext->pb = _ioContext.Pointer; + } + #endregion + + #region Methods + public AVStream* AddStream(AVCodec* codec) + { + var stream = ffmpeg.avformat_new_stream(_formatContext, codec); + if (stream == null) + throw new Exception("Could not allocate stream."); + if ((_formatContext->oformat->flags & ffmpeg.AVFMT_GLOBALHEADER) > 0) + stream->codec->flags |= ffmpeg.AV_CODEC_FLAG_GLOBAL_HEADER; + return stream; + } + + public void WriteHeader() + { + ffmpeg.avformat_write_header(_formatContext, null).ThrowOnError(); + } + + public void WritePacket(AVPacket* packet) + { + ffmpeg.av_interleaved_write_frame(_formatContext, packet).ThrowOnError(); + } + + public static MediaPacket EncodeFrame(AVCodecContext* codecContext, MediaFrame mediaFrame) + { + var frame = mediaFrame.Pointer; + var packet = ffmpeg.av_packet_alloc(); + ffmpeg.av_init_packet(packet); + + ffmpeg.avcodec_send_frame(codecContext, frame).ThrowOnError(); + ffmpeg.avcodec_receive_packet(codecContext, packet).ThrowOnError(); + + return new MediaPacket(packet); + } + + public void WriteFrame(AVCodecContext* codecContext, MediaFrame mediaFrame) + { + using (var packet = EncodeFrame(codecContext, mediaFrame)) + WritePacket(packet.Pointer); + } + + public void WriteTrailer() + { + ffmpeg.av_write_trailer(_formatContext).ThrowOnError(); + } + + public void Dispose() + { + if (_formatContext != null) + { + ffmpeg.avformat_free_context(_formatContext); + _formatContext = null; + } + if (_ioContext != null) + { + _ioContext.Dispose(); + _ioContext = null; + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaPacket.cs b/src/SharpGrabber.Converter/MediaPacket.cs new file mode 100644 index 0000000..3c2da77 --- /dev/null +++ b/src/SharpGrabber.Converter/MediaPacket.cs @@ -0,0 +1,36 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + /// + /// Wrapper for / + /// + public sealed unsafe class MediaPacket : IDisposable + { + #region Properties + public AVPacket* Pointer { get; private set; } + #endregion + + #region Constructor + public MediaPacket(AVPacket* packet) + { + Pointer = packet; + } + #endregion + + #region Methods + public void Dispose() + { + if (Pointer != null) + { + var packet = Pointer; + ffmpeg.av_packet_unref(packet); + Pointer = null; + } + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/MediaStreamSource.cs b/src/SharpGrabber.Converter/MediaStreamSource.cs new file mode 100644 index 0000000..9504d2c --- /dev/null +++ b/src/SharpGrabber.Converter/MediaStreamSource.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + public enum MediaStreamType { Audio, Video } + + class MediaStreamSource + { + #region Properties + public Uri Path { get; } + + public MediaStreamType StreamType { get; } + #endregion + + #region Constructors + public MediaStreamSource(Uri path, MediaStreamType streamType) + { + Path = path; + StreamType = streamType; + } + #endregion + } +} diff --git a/src/SharpGrabber.Converter/SharpGrabber.Converter.csproj b/src/SharpGrabber.Converter/SharpGrabber.Converter.csproj new file mode 100644 index 0000000..873dbbb --- /dev/null +++ b/src/SharpGrabber.Converter/SharpGrabber.Converter.csproj @@ -0,0 +1,19 @@ + + + + netstandard2.0 + + + + true + + + + true + + + + + + + diff --git a/src/SharpGrabber.Converter/VideoFrameConverter.cs b/src/SharpGrabber.Converter/VideoFrameConverter.cs new file mode 100644 index 0000000..3b729a7 --- /dev/null +++ b/src/SharpGrabber.Converter/VideoFrameConverter.cs @@ -0,0 +1,61 @@ +using FFmpeg.AutoGen; +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Runtime.InteropServices; +using System.Text; + +namespace DotNetTools.SharpGrabber.Converter +{ + public sealed unsafe class VideoFrameConverter : IDisposable + { + private readonly IntPtr _convertedFrameBufferPtr; + private readonly Size _destinationSize; + private readonly byte_ptrArray4 _dstData; + private readonly int_array4 _dstLinesize; + private readonly SwsContext* _pConvertContext; + + public VideoFrameConverter(Size sourceSize, AVPixelFormat sourcePixelFormat, + Size destinationSize, AVPixelFormat destinationPixelFormat) + { + _destinationSize = destinationSize; + + _pConvertContext = ffmpeg.sws_getContext(sourceSize.Width, sourceSize.Height, sourcePixelFormat, + destinationSize.Width, + destinationSize.Height, destinationPixelFormat, + ffmpeg.SWS_FAST_BILINEAR, null, null, null); + if (_pConvertContext == null) throw new ApplicationException("Could not initialize the conversion context."); + + var convertedFrameBufferSize = ffmpeg.av_image_get_buffer_size(destinationPixelFormat, destinationSize.Width, destinationSize.Height, 1); + _convertedFrameBufferPtr = Marshal.AllocHGlobal(convertedFrameBufferSize); + _dstData = new byte_ptrArray4(); + _dstLinesize = new int_array4(); + + ffmpeg.av_image_fill_arrays(ref _dstData, ref _dstLinesize, (byte*)_convertedFrameBufferPtr, destinationPixelFormat, destinationSize.Width, destinationSize.Height, 1); + } + + public void Dispose() + { + Marshal.FreeHGlobal(_convertedFrameBufferPtr); + ffmpeg.sws_freeContext(_pConvertContext); + } + + public AVFrame Convert(AVFrame sourceFrame) + { + ffmpeg.sws_scale(_pConvertContext, sourceFrame.data, sourceFrame.linesize, 0, sourceFrame.height, _dstData, _dstLinesize); + + var data = new byte_ptrArray8(); + data.UpdateFrom(_dstData); + var linesize = new int_array8(); + linesize.UpdateFrom(_dstLinesize); + + return new AVFrame + { + data = data, + linesize = linesize, + width = _destinationSize.Width, + height = _destinationSize.Height + }; + } + } +} diff --git a/src/SharpGrabber.Desktop/App.xaml b/src/SharpGrabber.Desktop/App.xaml new file mode 100644 index 0000000..4045f1c --- /dev/null +++ b/src/SharpGrabber.Desktop/App.xaml @@ -0,0 +1,8 @@ + + + + + + diff --git a/src/SharpGrabber.Desktop/App.xaml.cs b/src/SharpGrabber.Desktop/App.xaml.cs new file mode 100644 index 0000000..6385528 --- /dev/null +++ b/src/SharpGrabber.Desktop/App.xaml.cs @@ -0,0 +1,13 @@ +using Avalonia; +using Avalonia.Markup.Xaml; + +namespace SharpGrabber.Desktop +{ + public class App : Application + { + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml b/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml new file mode 100644 index 0000000..473a1fc --- /dev/null +++ b/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml @@ -0,0 +1,30 @@ + + + + + + + + + diff --git a/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml.cs b/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml.cs new file mode 100644 index 0000000..4aa881a --- /dev/null +++ b/src/SharpGrabber.Desktop/Components/LoadingSpinner.xaml.cs @@ -0,0 +1,19 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace SharpGrabber.Desktop.Components +{ + public class LoadingSpinner : UserControl + { + public LoadingSpinner() + { + this.InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml b/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml new file mode 100644 index 0000000..ddb1491 --- /dev/null +++ b/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml.cs b/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml.cs new file mode 100644 index 0000000..a687b8e --- /dev/null +++ b/src/SharpGrabber.Desktop/Components/MediaResourceView.xaml.cs @@ -0,0 +1,120 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using DotNetTools.SharpGrabber.Media; +using SharpGrabber.Desktop; +using SharpGrabber.Desktop.UI; +using SharpGrabber.Desktop.ViewModel; +using System; +using System.Collections.Generic; +using System.IO; + +namespace SharpGrabber.Desktop.Components +{ + public class MediaResourceView : UserControl + { + #region Fields + private Button btnDownload, btnCopyLink; + private DrawingPresenter iconCheck, iconVideo, iconAudio, iconCreate; + #endregion + + #region Properties + public GrabbedMediaViewModel GrabbedMedia => DataContext as GrabbedMediaViewModel; + + public MainWindow MainWindow => Application.Current.MainWindow as MainWindow; + #endregion + + public MediaResourceView(GrabbedMediaViewModel viewModel) + { + DataContext = viewModel; + InitializeComponent(); + + var channels = GrabbedMedia.Media.Channels; + DrawingPresenter icon; + + if (viewModel.IsComposition) + { + icon = iconCreate; + btnCopyLink.IsVisible = false; + ((TextBlock)btnDownload.Content).Text = "Download & convert"; + } + else + switch (channels) + { + case MediaChannels.Both: + icon = iconCheck; + break; + + case MediaChannels.Audio: + icon = iconAudio; + break; + + case MediaChannels.Video: + icon = iconVideo; + break; + + default: + throw new NotSupportedException($"Media channel of {channels} is not supported."); + } + icon.IsVisible = true; + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + + btnDownload = this.Find + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SharpGrabber.Desktop/MainWindow.xaml.cs b/src/SharpGrabber.Desktop/MainWindow.xaml.cs new file mode 100644 index 0000000..e17b52f --- /dev/null +++ b/src/SharpGrabber.Desktop/MainWindow.xaml.cs @@ -0,0 +1,409 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Media.Imaging; +using DotNetTools.SharpGrabber; +using DotNetTools.SharpGrabber.Converter; +using DotNetTools.SharpGrabber.Media; +using FFmpeg.AutoGen; +using SharpGrabber.Desktop.Components; +using SharpGrabber.Desktop.ViewModel; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace SharpGrabber.Desktop +{ + public class MainWindow : Window + { + #region Fields + private bool _uiEnabled = true; + private TextBox tbUrl; + private TextBlock tbPlaceholder, txtGrab; + private Button btnGrab, btnPaste, btnSaveImages; + private LoadingSpinner spGrab; + private Grid overlayRoot, noContent; + private TextBlock txtMsgTitle, txtMsgContent, txtTitle; + private Button btnMsgOk; + private TextBlock txtMediaTitle; + private TextBlock[] txtCol = new TextBlock[3]; + private Image img; + private LoadingSpinner imgSpinner; + private StackPanel resourceContainer; + private Grid basicInfo; + #endregion + + #region Properties + /// + /// Result of the last grab + /// + public GrabResult CurrentGrab { get; set; } + + public bool IsUIEnabled + { + get => _uiEnabled; + set + { + if (_uiEnabled == value) + return; + _uiEnabled = value; + btnGrab.IsEnabled = btnPaste.IsEnabled = btnSaveImages.IsEnabled = value; + txtGrab.IsVisible = value; + spGrab.IsVisible = !value; + resourceContainer.IsEnabled = value; + } + } + #endregion + + public MainWindow() + { + Initialized += MainWindow_Initialized; + + InitializeComponent(); + basicInfo.IsVisible = resourceContainer.IsVisible = false; +#if DEBUG + this.AttachDevTools(); +#endif + CheckLibrary(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + + tbUrl = this.FindControl("tbUrl"); + tbPlaceholder = this.FindControl("tbPlaceholder"); + btnGrab = this.FindControl