Files
FileFlowsPlugins/VideoNodes/VideoInfoHelper.cs
2024-02-25 07:22:10 +13:00

500 lines
20 KiB
C#

using System.Diagnostics;
using System.Globalization;
namespace FileFlows.VideoNodes;
public class VideoInfoHelper
{
private string ffMpegExe;
private ILogger Logger;
static Regex rgxTitle = new Regex(@"(?<=((^[\s]+title[\s]+:[\s])))(.*?)$", RegexOptions.Multiline);
static Regex rgxDuration = new Regex(@"(?<=((^[\s]+DURATION(\-[\w]+)?[\s]+:[\s])))([\d]+:?)+\.[\d]{1,7}", RegexOptions.Multiline);
static Regex rgxDuration2 = new Regex(@"(?<=((^[\s]+Duration:[\s])))([\d]+:?)+\.[\d]{1,7}", RegexOptions.Multiline);
static Regex rgxAudioSampleRate = new Regex(@"(?<=((,|\s)))[\d]+(?=([\s]?hz))", RegexOptions.IgnoreCase);
static Regex rgxAudioBitrate = new Regex(@"(?<=(, ))([\d]+)(?=( kb\/s))", RegexOptions.IgnoreCase);
static Regex rgxAudioBitrateFull = new Regex(@"^[\s]+BPS[^:]+: ([\d]+)", RegexOptions.Multiline);
static Regex rgxFilename = new Regex(@"(?<=((^[\s]+filename[\s]+:[\s])))(.*?)$", RegexOptions.Multiline);
static Regex rgxMimeType = new Regex(@"(?<=((^[\s]+mimetype[\s]+:[\s])))(.*?)$", RegexOptions.Multiline);
static int _ProbeSize = 25;
internal static int ProbeSize
{
get => _ProbeSize;
set
{
if (value < 5)
_ProbeSize = 5;
else if (value > 1000)
_ProbeSize = 1000;
else
_ProbeSize = value;
}
}
public VideoInfoHelper(string ffMpegExe, ILogger logger)
{
this.ffMpegExe = ffMpegExe;
this.Logger = logger;
}
public static string GetFFMpegPath(NodeParameters args) => args.GetToolPath("FFMpeg");
public Result<VideoInfo> Read(string filename)
=> ReadStatic(Logger, ffMpegExe, filename);
internal static Result<VideoInfo> ReadStatic(ILogger logger, string ffMpegExe, string filename)
{
#if(DEBUG) // UNIT TESTING
filename = filename.Replace("~/", Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + "/");
#endif
if (System.IO.File.Exists(filename) == false)
return Result<VideoInfo>.Fail("File not found: " + filename);
if (string.IsNullOrEmpty(ffMpegExe) || System.IO.File.Exists(ffMpegExe) == false)
return Result<VideoInfo>.Fail("FFmpeg not found: " + (ffMpegExe ?? "not passed in"));
try
{
using (var process = new Process())
{
process.StartInfo = new ProcessStartInfo();
process.StartInfo.FileName = ffMpegExe;
process.StartInfo.UseShellExecute = false;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.CreateNoWindow = true;
foreach (var arg in new[]
{
"-hide_banner",
"-probesize", ProbeSize + "M",
"-i",
filename,
})
{
process.StartInfo.ArgumentList.Add(arg);
}
process.Start();
string output = process.StandardError.ReadToEnd();
output = output.Replace("At least one output file must be specified", string.Empty).Trim();
string error = process.StandardError.ReadToEnd();
process.WaitForExit();
if (string.IsNullOrEmpty(error) == false && error != "At least one output file must be specified")
return Result<VideoInfo>.Fail("Failed reading ffmpeg info: " + error);
logger.ILog("Video Information:" + Environment.NewLine + output);
if (process.ExitCode != 0)
{
if (output.Contains("moov atom not found"))
return Result<VideoInfo>.Fail(
"The video file appears to be corrupted or incomplete. (moov atom not found)");
}
var vi = ParseOutput(logger, output);
vi.FileName = filename;
return vi;
}
}
catch (Exception ex)
{
logger.ELog(ex.Message, ex.StackTrace);
return Result<VideoInfo>.Fail("Failed reading video information: " + ex.Message);
}
}
public static VideoInfo ParseOutput(ILogger logger, string output)
{
var vi = new VideoInfo();
var rgxStreams = new Regex(@"Stream\s#[\d]+:[\d]+(.*?)(?=(Stream\s#[\d]|$))", RegexOptions.Singleline);
var streamMatches = rgxStreams.Matches(output);
int streamIndex = 0;
// get a rough estimate, bitrate: 346 kb/s
var rgxBitrate = new Regex(@"(?<=(bitrate: ))[\d\.]+(?!=( kb/s))");
var brMatch = rgxBitrate.Match(output);
if (brMatch.Success)
{
vi.Bitrate = float.Parse(brMatch.Value) * 1_000; // to convert to b/s
}
vi.Chapters = ParseChapters(output);
int subtitleIndex = 0;
int videoIndex = 0;
int audioIndex = 0;
int attachmentIndex = 0;
foreach (Match sm in streamMatches)
{
if (sm.Value.Contains(" Video: "))
{
var vs = ParseVideoStream(logger, sm.Value, output);
if (vs != null)
{
vs.Index = streamIndex;
vs.TypeIndex = videoIndex;
var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+");
if (match.Success)
vs.IndexString = match.Value;
vi.VideoStreams.Add(vs);
}
++videoIndex;
}
else if (sm.Value.Contains(" Audio: "))
{
var audio = ParseAudioStream(logger, sm.Value);
if (audio != null)
{
audio.TypeIndex = audioIndex;
audio.Index = streamIndex;
var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+");
if (match.Success)
audio.IndexString = match.Value;
vi.AudioStreams.Add(audio);
}
++audioIndex;
}
else if (sm.Value.Contains(" Subtitle: "))
{
var sub = ParseSubtitleStream(sm.Value);
if (sub != null)
{
sub.Index = streamIndex;
sub.TypeIndex = subtitleIndex;
var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+");
if (match.Success)
sub.IndexString = match.Value;
vi.SubtitleStreams.Add(sub);
}
++subtitleIndex;
}
else if (sm.Value.Contains(" Attachment: "))
{
AttachmentStream attachmentStream = ParseAttachmentStream(sm.Value);
if (attachmentStream != null)
{
attachmentStream.Index = streamIndex;
attachmentStream.TypeIndex = attachmentIndex;
var match = Regex.Match(sm.Value, @"(?<=(Stream #))[\d]+:[\d]+");
if (match.Success)
attachmentStream.IndexString = match.Value;
vi.Attachments.Add(attachmentStream);
}
++subtitleIndex;
}
++streamIndex;
}
return vi;
}
private static List<Chapter> ParseChapters(string output)
{
try
{
var rgxChatpers = new Regex("(?<=(Chapters:))(.*?)(?=(Stream))", RegexOptions.Singleline);
string strChapters;
if (rgxChatpers.TryMatch(output, out Match matchChapters))
strChapters = matchChapters.Value.Trim();
else
return new List<Chapter>();
var rgxChapter = new Regex("Chapter #(.*?)(?=(Chapter #|$))", RegexOptions.Singleline);
var chapters = new List<Chapter>();
var rgxTitle = new Regex(@"title[\s]*:[\s]*(.*?)$");
var rgxStart = new Regex(@"(?<=(start[\s]))[\d]+\.[\d]+");
var rgxEnd = new Regex(@"(?<=(end[\s]))[\d]+\.[\d]+");
foreach (Match match in rgxChapter.Matches(strChapters))
{
try
{
Chapter chapter = new Chapter();
if (rgxTitle.TryMatch(match.Value.Trim(), out Match title))
chapter.Title = title.Groups[1].Value;
if (rgxStart.TryMatch(match.Value, out Match start))
{
double startSeconds = double.Parse(start.Value);
chapter.Start = TimeSpan.FromSeconds(startSeconds);
}
if (rgxEnd.TryMatch(match.Value, out Match end))
{
double endSeconds = double.Parse(end.Value);
chapter.End = TimeSpan.FromSeconds(endSeconds);
}
if (chapter.Start > TimeSpan.Zero || chapter.End > TimeSpan.Zero)
{
chapters.Add(chapter);
}
}
catch (Exception ) { }
}
return chapters;
}catch (Exception) { return new List<Chapter>(); }
}
public static VideoStream ParseVideoStream(ILogger logger, string info, string fullOutput)
{
// Stream #0:0(eng): Video: h264 (High), yuv420p(tv, bt709/unknown/unknown, progressive), 1920x1080 [SAR 1:1 DAR 16:9], 23.98 fps, 23.98 tbr, 1k tbn (default)
string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First();
VideoStream vs = new VideoStream();
vs.IsImage = info.Contains("(attached pic)");
var matchCodecTag = new Regex(@": Video: [^(,]+\([^)]+\)[^(,]+\(([^)]+)\)").Match(line);
if (matchCodecTag.Success)
{
vs.CodecTag = matchCodecTag.Groups[1].Value;
if (vs.CodecTag.IndexOf(" /", StringComparison.Ordinal) > 0)
vs.CodecTag = vs.CodecTag.Substring(0, vs.CodecTag.IndexOf(" /")).Trim();
}
vs.Codec = line.Substring(line.IndexOf("Video: ") + "Video: ".Length).Replace(",", "").Trim().Split(' ').First().ToLower();
vs.PixelFormat = GetDecoderPixelFormat(line);
vs.Bits = GetBitDepthFromStreamInfo(logger, info);
var dimensions = Regex.Match(line, @"([\d]{3,})x([\d]{3,})");
if (int.TryParse(dimensions.Groups[1].Value, out int width))
vs.Width = width;
if (int.TryParse(dimensions.Groups[2].Value, out int height))
vs.Height = height;
if (Regex.IsMatch(line, @"([\d]+(\.[\d]+)?)\sfps") &&
float.TryParse(Regex.Match(line, @"([\d]+(\.[\d]+)?)\sfps").Groups[1].Value, out float fps))
{
logger?.ILog("Frames Per Second: " + fps);
vs.FramesPerSecond = fps;
}
var rgxBps = new Regex(@"(?<=((BPS(\-[\w]+)?[\s]*:[\s])))([\d]+)");
if (rgxBps.IsMatch(info) && float.TryParse(rgxBps.Match(info).Value, out float bps))
vs.Bitrate = bps;
if (rgxDuration.IsMatch(info) && TimeSpan.TryParse(rgxDuration.Match(info).Value, out TimeSpan duration) && duration.TotalSeconds > 0)
{
vs.Duration = duration;
logger?.ILog("Video stream duration: " + vs.Duration);
}
else if (rgxDuration2.IsMatch(fullOutput) && TimeSpan.TryParse(rgxDuration2.Match(fullOutput).Value, out TimeSpan duration2) && duration2.TotalSeconds > 0)
{
vs.Duration = duration2;
logger?.ILog("Video stream duration: " + vs.Duration);
}
else
{
logger?.ILog("Failed to read duration for VideoStream: " + info);
}
// As per https://video.stackexchange.com/a/33827
// "HDR is only the new transfer function" (PQ or HLG)
vs.HDR = info.Contains("arib-std-b67") || info.Contains("smpte2084");
vs.DolbyVision = info.Contains("DOVI configuration record");
return vs;
}
public static AudioStream ParseAudioStream(ILogger logger, string info)
{
// Stream #0:1(eng): Audio: dts (DTS), 48000 Hz, stereo, fltp, 1536 kb/s (default)
string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First();
var parts = line.Split(",").Select(x => x?.Trim() ?? "").ToArray();
AudioStream audio = new AudioStream();
audio.Title = "";
// this isnt type index, this is overall index
audio.TypeIndex = int.Parse(Regex.Match(line, @"#([\d]+):([\d]+)").Groups[2].Value) - 1;
audio.Codec = parts[0].Substring(parts[0].IndexOf("Audio: ") + "Audio: ".Length).Trim().Split(' ').First().ToLower() ?? string.Empty;
if (audio.Codec.EndsWith(","))
audio.Codec = audio.Codec[..^1].Trim();
audio.Language = GetLanguage(line);
audio.Default = info.Contains("(default)");
// if (info.IndexOf("0 channels", StringComparison.Ordinal) >= 0)
// {
// logger?.WLog("Stream contained '0 Channels'");
// audio.Channels = 0;
// }
// else
{
try
{
//Logger.ILog("codec: " + vs.Codec);
if (parts[2] == "stereo")
audio.Channels = 2;
else if (parts[2] == "mono")
audio.Channels = 1;
else if (Regex.IsMatch(parts[2], @"^[\d]+(\.[\d]+)?"))
{
var matchValue = Regex.Match(parts[2], @"^[\d]+(\.[\d]+)?").Value;
CultureInfo culture = CultureInfo.InvariantCulture; // Use invariant culture for consistent parsing
if (float.TryParse(matchValue, NumberStyles.Float, culture, out float channels))
{
audio.Channels = channels;
logger?.ILog("Detected audio channels: " + audio.Channels + ", from " + parts[2]);
}
else
{
logger?.WLog($"Failed to parse value '{matchValue}' as a float.");
}
}
else if (line.Contains(" 7.1"))
audio.Channels = 7.1f;
else if (line.Contains(" 7.2"))
audio.Channels = 7.2f;
else if (line.Contains(" 5.1"))
audio.Channels = 5.1f;
else if (line.Contains(" 5.0"))
audio.Channels = 5f;
else if (line.Contains(" 4.1"))
audio.Channels = 4.1f;
else
{
logger?.WLog("Unable to detect channels from: " + line);
}
logger?.ILog("Audio channels: " + audio.Channels + ", from " + parts[2]);
}
catch (Exception ex)
{
logger?.WLog("Failed to parse audio channels: " + ex.Message + "\n" + "From line: " + line);
}
}
var match = rgxAudioSampleRate.Match(info);
if (match.Success)
audio.SampleRate = int.Parse(match.Value);
if (rgxTitle.IsMatch(info))
audio.Title = rgxTitle.Match(info).Value.Trim();
if (rgxDuration.IsMatch(info))
audio.Duration = TimeSpan.Parse(rgxDuration.Match(info).Value);
try
{
if (rgxAudioBitrate.IsMatch(line))
{
audio.Bitrate = int.Parse(rgxAudioBitrate.Match(line).Value) * 1000; // this is NOT 1024
}
else if (rgxAudioBitrateFull.IsMatch(info))
{
audio.Bitrate = float.Parse(rgxAudioBitrateFull.Match(info).Groups[1].Value);
}
}
catch(Exception) { }
return audio;
}
public static SubtitleStream ParseSubtitleStream(string info)
{
// Stream #0:1(eng): Audio: dts (DTS), 48000 Hz, stereo, fltp, 1536 kb/s (default)
string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First();
var parts = line.Split(",").Select(x => x?.Trim() ?? "").ToArray();
SubtitleStream sub = new SubtitleStream();
sub.TypeIndex = int.Parse(Regex.Match(line, @"#([\d]+):([\d]+)").Groups[2].Value);
sub.Codec = line.Substring(line.IndexOf("Subtitle: ") + "Subtitle: ".Length).Trim().Split(' ').First().ToLower();
if (sub.Codec.EndsWith(","))
sub.Codec = sub.Codec[..^1].Trim();
sub.Language = GetLanguage(line);
sub.Default = info.Contains("(default)");
if (rgxTitle.IsMatch(info))
sub.Title = rgxTitle.Match(info).Value.Trim();
sub.Forced = info.ToLower().Contains("(forced)");
return sub;
}
private static AttachmentStream ParseAttachmentStream(string info)
{
// Stream #0:6: Attachment: ttf
// Metadata:
// filename : FF Tisa OT XBold.ttf
// mimetype : application/x-truetype-font
string line = info.Split(new string[] { "\r\n", "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries).First();
var parts = line.Split(",").Select(x => x?.Trim() ?? "").ToArray();
AttachmentStream stream = new AttachmentStream();
stream.TypeIndex = int.Parse(Regex.Match(line, @"#([\d]+):([\d]+)").Groups[2].Value);
stream.Codec = line.Substring(line.IndexOf("Attachment: ") + "Attachment: ".Length).Trim().Split(' ').First().ToLower();
if (stream.Codec.EndsWith(","))
stream.Codec = stream.Codec[..^1].Trim();
if (rgxFilename.IsMatch(info))
stream.FileName = rgxFilename.Match(info).Value.Trim();
if (rgxMimeType.IsMatch(info))
stream.MimeType = rgxMimeType.Match(info).Value.Trim();
return stream;
}
private static string GetLanguage(string line)
{
var langSection = Regex.Match(line, @"(?<=(Stream\s#[\d]+:[\d]+))[^:]+");
if (langSection.Success == false)
return string.Empty;
var lang = Regex.Match(langSection.Value, @"(?<=\()[^)]+").Value?.ToLower() ?? string.Empty;
if (lang == "und")
return string.Empty;
return lang;
}
/// <summary>
/// Extracts the supported pixel format from an FFmpeg output line for hardware decoding.
/// </summary>
/// <param name="line">The FFmpeg output line containing video stream information.</param>
/// <returns>The supported pixel format (e.g., "yuv420p" or "p010le"), or an empty string if not found or not supported.</returns>
/// <remarks>
/// Supports "yuv420p" and "p010le" formats. Handles cases where "p010le" has no additional specifiers, defaulting to "yuv420p".
/// Adjust the regular expression or default behavior based on specific hardware and FFmpeg output format.
/// </remarks>
static string GetDecoderPixelFormat(string line)
{
// only p010le confirmed working so far
if(Regex.IsMatch(line, @"p(0)?10l(b)?e"))
return "p010le";
if(line.IndexOf("yuv420p", StringComparison.Ordinal) > 0)
return "nv12"; // use nv12 instead of yuv420p
// if (line.IndexOf("nv12", StringComparison.Ordinal) >= 0)
// return "nv12";
// if (line.IndexOf("yuv444p", StringComparison.Ordinal) >= 0)
// return "yuv444p";
return string.Empty;
}
/// <summary>
/// Extracts the number of bits from the given video stream information.
/// </summary>
/// <param name="logger">the logger to use for logging</param>
/// <param name="line">The video stream information string.</param>
/// <returns>The number of bits, or 0 if unknown.</returns>
static int GetBitDepthFromStreamInfo(ILogger logger, string line)
{
if (line.Contains("p10le") || line.Replace(" ", string.Empty).Contains("main10"))
{
logger?.ILog("10-Bit detected");
return 10;
}
if (line.Contains("p12le"))
{
logger?.ILog("12-Bit detected");
return 12;
}
if (line.Contains("yuv420p") || line.Contains("nv12"))
{
logger?.ILog("8-Bit detected");
return 8;
}
logger?.ILog("Bits not detected in video");
return 0; // Unknown
}
}