From 38a07c66ced0d909c9891f030093445ea6042f8e Mon Sep 17 00:00:00 2001 From: John Andrews Date: Wed, 24 Jan 2024 13:17:54 +1300 Subject: [PATCH] FF-1187 - ffmpeg executor now reports additional info --- ChecksumNodes/Tests/ChecksumTests.cs | 8 +- VideoNodes/FFMpegEncoder.cs | 95 +++- .../FfmpegBuilder_VideoEncodeTests.cs | 4 +- VideoNodes/Tests/_LocalFileService.cs | 483 ++++++++++++++++++ VideoNodes/Tests/_TestBase.cs | 1 + VideoNodes/VideoNodes/EncodingNode.cs | 5 + 6 files changed, 567 insertions(+), 29 deletions(-) create mode 100644 VideoNodes/Tests/_LocalFileService.cs diff --git a/ChecksumNodes/Tests/ChecksumTests.cs b/ChecksumNodes/Tests/ChecksumTests.cs index b88738cd..7d639a72 100644 --- a/ChecksumNodes/Tests/ChecksumTests.cs +++ b/ChecksumNodes/Tests/ChecksumTests.cs @@ -11,7 +11,7 @@ namespace ChecksumNodes.Tests [TestMethod] public void Checksum_MD5_Large() { - var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, ""); + var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, "", null); var output = new MD5().Execute(args); Assert.IsTrue(args.Variables.ContainsKey("MD5")); Assert.IsFalse(string.IsNullOrWhiteSpace(args.Variables["MD5"] as string)); @@ -20,7 +20,7 @@ namespace ChecksumNodes.Tests [TestMethod] public void Checksum_SHA1_Large() { - var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, ""); + var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, "", null); var output = new SHA1().Execute(args); Assert.IsTrue(args.Variables.ContainsKey("SHA1")); Assert.IsFalse(string.IsNullOrWhiteSpace(args.Variables["SHA1"] as string)); @@ -29,7 +29,7 @@ namespace ChecksumNodes.Tests [TestMethod] public void Checksum_SHA256_Large() { - var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, ""); + var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, "", null); var output = new SHA256().Execute(args); Assert.IsTrue(args.Variables.ContainsKey("SHA256")); Assert.IsFalse(string.IsNullOrWhiteSpace(args.Variables["SHA256"] as string)); @@ -38,7 +38,7 @@ namespace ChecksumNodes.Tests [TestMethod] public void Checksum_SHA512_Large() { - var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, ""); + var args = new NodeParameters(@"D:\videos\Injustice.mkv", new TestLogger(), false, "", null); var output = new SHA512().Execute(args); Assert.IsTrue(args.Variables.ContainsKey("SHA512")); Assert.IsFalse(string.IsNullOrWhiteSpace(args.Variables["SHA512"] as string)); diff --git a/VideoNodes/FFMpegEncoder.cs b/VideoNodes/FFMpegEncoder.cs index 63e72a06..7c7b77f1 100644 --- a/VideoNodes/FFMpegEncoder.cs +++ b/VideoNodes/FFMpegEncoder.cs @@ -14,9 +14,14 @@ namespace FileFlows.VideoNodes TaskCompletionSource outputCloseEvent, errorCloseEvent; private Regex rgxTime = new Regex(@"(?<=(time=))([\d]+:?)+\.[\d]+"); + private Regex rgxFps = new Regex(@"(?<=(fps=[\s]?))([\d]+)"); + private Regex rgxSpeed = new Regex(@"(?<=(speed=[\s]?))([\d]+(\.[\d]+)?)"); + private Regex rgxBitrate = new Regex(@"(?<=(bitrate=[\s]?))([\d]+(\.[\d]+)?)kbits"); public delegate void TimeEvent(TimeSpan time); public event TimeEvent AtTime; + public delegate void StatChange(string name, object value); + public event StatChange OnStatChange; private Process process; @@ -90,6 +95,37 @@ namespace FileFlows.VideoNodes { var result = new ProcessResult(); + var hwDecoderIndex = arguments.FindIndex(x => x == "-hwaccel"); + if (hwDecoderIndex >= 0 && hwDecoderIndex < arguments.Count - 2) + { + var decoder = arguments[hwDecoderIndex + 1].ToLowerInvariant(); + foreach(var dec in new [] + { + ("qsv", "QSV"), ("cuda", "NVIDIA"), ("amf", "AMD"), ("vulkan", "Vulkan"), ("vaapi", "VAAPI"), ("dxva2", "dxva2"), + ("d3d11va", "d3d11va"), ("opencl", "opencl") + }) + { + if (decoder == dec.Item1) + { + OnStatChange?.Invoke("Decoder", dec.Item2); + break; + } + } + } + + if(arguments.Any(x => x.ToLowerInvariant().Contains("hevc_qsv") || x.ToLowerInvariant().Contains("h264_qsv") || x.ToLowerInvariant().Contains("av1_qsv"))) + OnStatChange?.Invoke("Encoder", "QSV"); + else if(arguments.Any(x => x.ToLowerInvariant().Contains("_nvenc"))) + OnStatChange?.Invoke("Encoder", "NVIDIA"); + else if(arguments.Any(x => x.ToLowerInvariant().Contains("_amf"))) + OnStatChange?.Invoke("Encoder", "AMD"); + else if(arguments.Any(x => x.ToLowerInvariant().Contains("_vaapi"))) + OnStatChange?.Invoke("Encoder", "VAAPI"); + else if(arguments.Any(x => x.ToLowerInvariant().Contains("_videotoolbox"))) + OnStatChange?.Invoke("Encoder", "VideoToolbox"); + else if(arguments.Any(x => x.ToLowerInvariant().Contains("libx") || x.ToLowerInvariant().Contains("libvpx"))) + OnStatChange?.Invoke("Encoder", "CPU"); + using (var process = new Process()) { this.process = process; @@ -174,6 +210,7 @@ namespace FileFlows.VideoNodes return result; } + public void OnOutputDataReceived(object sender, DataReceivedEventArgs e) { // The output stream has been closed i.e. the process has terminated @@ -183,18 +220,7 @@ namespace FileFlows.VideoNodes } else { - if (e.Data.Contains("Skipping NAL unit")) - return; // just slighlty ignore these - if (rgxTime.IsMatch(e.Data)) - { - var timeString = rgxTime.Match(e.Data).Value; - var ts = TimeSpan.Parse(timeString); - Logger.DLog("TimeSpan Detected: " + ts); - if (AtTime != null) - AtTime.Invoke(ts); - } - Logger.ILog(e.Data); - outputBuilder.AppendLine(e.Data); + CheckOutputLine(e.Data); } } @@ -210,22 +236,45 @@ namespace FileFlows.VideoNodes Logger.ELog(e.Data); errorBuilder.AppendLine(e.Data); } - else if (e.Data.Contains("Skipping NAL unit")) + else { - return; // just slighlty ignore these + CheckOutputLine(e.Data); + } + } + + private void CheckOutputLine(string line) + { + if (line.Contains("Skipping NAL unit")) + return; // just slightly ignore these + + if (rgxTime.IsMatch(line)) + { + var timeString = rgxTime.Match(line).Value; + var ts = TimeSpan.Parse(timeString); + Logger.DLog("TimeSpan Detected: " + ts); + AtTime?.Invoke(ts); + } + + if (line.Contains("Exit Code")) + { + OnStatChange?.Invoke("Speed", null); + OnStatChange?.Invoke("Bitrate", null); + OnStatChange?.Invoke("FPS", null); } else { - if (rgxTime.IsMatch(e.Data)) - { - var timeString = rgxTime.Match(e.Data).Value; - var ts = TimeSpan.Parse(timeString); - if (AtTime != null) - AtTime.Invoke(ts); - } - Logger.ILog(e.Data); - outputBuilder.AppendLine(e.Data); + if (rgxSpeed.TryMatch(line, out Match matchSpeed)) + OnStatChange?.Invoke("Speed", matchSpeed.Value); + + if (rgxBitrate.TryMatch(line, out Match matchBitrate)) + OnStatChange?.Invoke("Bitrate", matchBitrate.Value); + + if (rgxBitrate.TryMatch(line, out Match matchFps) && int.TryParse(matchFps.Value, out int fps)) + OnStatChange?.Invoke("FPS", fps); } + + Logger.ILog(line); + outputBuilder.AppendLine(line); } diff --git a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_VideoEncodeTests.cs b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_VideoEncodeTests.cs index 42af26b0..db1e349c 100644 --- a/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_VideoEncodeTests.cs +++ b/VideoNodes/Tests/FfmpegBuilderTests/FfmpegBuilder_VideoEncodeTests.cs @@ -19,7 +19,7 @@ public class FfmpegBuilder_VideoEncode_VideoEncodeTests: TestBase string ffmpeg = FfmpegPath; var vi = new VideoInfoHelper(ffmpeg, logger); var vii = vi.Read(file); - var args = new NodeParameters(file, logger, false, string.Empty, null); + var args = new NodeParameters(file, logger, false, string.Empty, new LocalFileService()); args.GetToolPathActual = (string tool) => ffmpeg; args.TempPath = TempPath; args.Parameters.Add("VideoInfo", vii); @@ -54,7 +54,7 @@ public class FfmpegBuilder_VideoEncode_VideoEncodeTests: TestBase h265 ? FfmpegBuilderVideoEncode.CODEC_H265 : FfmpegBuilderVideoEncode.CODEC_H264; - var result = Encode(codec, quality, hardware, TestFile_120_mbps_4k_uhd_hevc_10bit, + var result = Encode(codec, quality, hardware, TestFile_Sitcom, $"{(hardware ? "nvidia" : "cpu")}_h26{(h265 ? "5" : "4")}{(bit10 ? "_10bit" : "")}_{quality}.mkv"); } diff --git a/VideoNodes/Tests/_LocalFileService.cs b/VideoNodes/Tests/_LocalFileService.cs new file mode 100644 index 00000000..b137ee64 --- /dev/null +++ b/VideoNodes/Tests/_LocalFileService.cs @@ -0,0 +1,483 @@ +#if(DEBUG) +using FileFlows.Plugin; +using FileFlows.Plugin.Models; +using FileFlows.Plugin.Services; +using System.IO; +using FileHelper = FileFlows.Plugin.Helpers.FileHelper; + +namespace VideoNodes.Tests; + +public class LocalFileService : IFileService +{ + /// + /// Gets or sets the path separator for the file system + /// + public char PathSeparator { get; init; } = Path.DirectorySeparatorChar; + + /// + /// Gets or sets the allowed paths the file service can access + /// + public string[] AllowedPaths { get; init; } + + /// + /// Gets or sets a function for replacing variables in a string. + /// + /// + /// The function takes a string input, a boolean indicating whether to strip missing variables, + /// and a boolean indicating whether to clean special characters. + /// + public ReplaceVariablesDelegate ReplaceVariables { get; set; } + + /// + /// Gets or sets the permissions to use for files + /// + public int? Permissions { get; set; } + + /// + /// Gets or sets the owner:group to use for files + /// + public string OwnerGroup { get; set; } + + /// + /// Gets or sets the logger used for logging + /// + public ILogger? Logger { get; set; } + + public Result GetFiles(string path, string searchPattern = "", bool recursive = false) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + return Directory.GetFiles(path, searchPattern ?? string.Empty, + recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly); + } + catch (Exception) + { + return new string[] { }; + } + } + + public Result GetDirectories(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + return Directory.GetDirectories(path); + } + catch (Exception) + { + return new string[] { }; + } + } + + public Result DirectoryExists(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + return Directory.Exists(path); + } + catch (Exception) + { + return false; + } + } + + public Result DirectoryDelete(string path, bool recursive = false) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + Directory.Delete(path, recursive); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result DirectoryMove(string path, string destination) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + if (IsProtectedPath(ref destination)) + return Result.Fail("Cannot access protected path: " + destination); + try + { + Directory.Move(path, destination); + SetPermissions(destination); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result DirectoryCreate(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + var dirInfo = new DirectoryInfo(path); + if (dirInfo.Exists == false) + dirInfo.Create(); + SetPermissions(path); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileExists(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + return File.Exists(path); + } + catch (Exception) + { + return false; + } + } + + public Result FileInfo(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + FileInfo fileInfo = new FileInfo(path); + + return new FileInformation + { + CreationTime = fileInfo.CreationTime, + CreationTimeUtc = fileInfo.CreationTimeUtc, + LastWriteTime = fileInfo.LastWriteTime, + LastWriteTimeUtc = fileInfo.LastWriteTimeUtc, + Extension = fileInfo.Extension.TrimStart('.'), + Name = fileInfo.Name, + FullName = fileInfo.FullName, + Length = fileInfo.Length, + Directory = fileInfo.DirectoryName + }; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileDelete(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + var fileInfo = new FileInfo(path); + if(fileInfo.Exists) + fileInfo.Delete(); + return true; + } + catch (Exception) + { + return false; + } + } + + public Result FileSize(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false) + return Result.Fail("File does not exist"); + return fileInfo.Length; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileCreationTimeUtc(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false) + return Result.Fail("File does not exist"); + return fileInfo.CreationTimeUtc; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileLastWriteTimeUtc(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false) + return Result.Fail("File does not exist"); + return fileInfo.LastWriteTimeUtc; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileMove(string path, string destination, bool overwrite = true) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + if (IsProtectedPath(ref destination)) + return Result.Fail("Cannot access protected path: " + destination); + try + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false) + return Result.Fail("File does not exist"); + var destDir = new FileInfo(destination).Directory; + if (destDir.Exists == false) + { + destDir.Create(); + SetPermissions(destDir.FullName); + } + + fileInfo.MoveTo(destination, overwrite); + SetPermissions(destination); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileCopy(string path, string destination, bool overwrite = true) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + if (IsProtectedPath(ref destination)) + return Result.Fail("Cannot access protected path: " + destination); + try + { + var fileInfo = new FileInfo(path); + if (fileInfo.Exists == false) + return Result.Fail("File does not exist"); + + var destDir = new FileInfo(destination).Directory; + if (destDir.Exists == false) + { + destDir.Create(); + SetPermissions(destDir.FullName); + } + + fileInfo.CopyTo(destination, overwrite); + SetPermissions(destination); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public Result FileAppendAllText(string path, string text) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + File.AppendAllText(path, text); + SetPermissions(path); + return true; + } + catch (Exception ex) + { + return Result.Fail(ex.Message); + } + } + + public bool FileIsLocal(string path) => true; + + /// + /// Gets the local path + /// + /// the path + /// the local path to the file + public Result GetLocalPath(string path) + => Result.Success(path); + + public Result Touch(string path) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + + if (DirectoryExists(path).Is(true)) + { + try + { + Directory.SetLastWriteTimeUtc(path, DateTime.UtcNow); + return true; + } + catch (Exception ex) + { + return Result.Fail("Failed to touch directory: " + ex.Message); + } + } + + try + { + if (File.Exists(path)) + File.SetLastWriteTimeUtc(path, DateTime.UtcNow); + else + { + File.Create(path); + SetPermissions(path); + } + + return true; + } + catch (Exception ex) + { + return Result.Fail($"Failed to touch file: '{path}' => {ex.Message}"); + } + } + + public Result SetCreationTimeUtc(string path, DateTime date) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + if (!File.Exists(path)) + return Result.Fail("File not found."); + + File.SetCreationTimeUtc(path, date); + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Fail($"Error setting creation time: {ex.Message}"); + } + } + + public Result SetLastWriteTimeUtc(string path, DateTime date) + { + if (IsProtectedPath(ref path)) + return Result.Fail("Cannot access protected path: " + path); + try + { + if (!File.Exists(path)) + return Result.Fail("File not found."); + + File.SetLastWriteTimeUtc(path, date); + return Result.Success(true); + } + catch (Exception ex) + { + return Result.Fail($"Error setting last write time: {ex.Message}"); + } + } + + /// + /// Checks if a path is accessible by the file server + /// + /// the path to check + /// true if accessible, otherwise false + private bool IsProtectedPath(ref string path) + { + if (OperatingSystem.IsWindows()) + path = path.Replace("/", "\\"); + else + path = path.Replace("\\", "/"); + + if(ReplaceVariables != null) + path = ReplaceVariables(path, true); + + if (FileHelper.IsSystemDirectory(path)) + return true; // a system directory, no access + + if (AllowedPaths?.Any() != true) + return false; // no allowed paths configured, allow all + + if (OperatingSystem.IsWindows()) + path = path.ToLowerInvariant(); + + for(int i=0;i logMethod = null) + { + logMethod ??= (string message) => Logger?.ILog(message); + + permissions = permissions != null && permissions > 0 ? permissions : Permissions; + if (permissions == null || permissions < 1) + permissions = 777; + + + if ((File.Exists(path) == false && Directory.Exists(path) == false)) + { + logMethod("SetPermissions: File doesnt existing, skipping"); + return; + } + + //StringLogger stringLogger = new StringLogger(); + var logger = new TestLogger(); + + bool isFile = new FileInfo(path).Exists; + + FileHelper.SetPermissions(logger, path, file: isFile, + permissions: permissions.Value.ToString("D3")); + + FileHelper.ChangeOwner(logger, path, file: isFile, ownerGroup: OwnerGroup); + + logMethod(logger.ToString()); + + return; + + + if (OperatingSystem.IsLinux()) + { + var filePermissions = FileHelper.ConvertLinuxPermissionsToUnixFileMode(permissions.Value); + if (filePermissions == UnixFileMode.None) + { + logMethod("SetPermissions: Invalid file permissions: " + permissions.Value); + return; + } + + File.SetUnixFileMode(path, filePermissions); + logMethod($"SetPermissions: Permission [{filePermissions}] set on file: " + path); + } + + } +} +#endif \ No newline at end of file diff --git a/VideoNodes/Tests/_TestBase.cs b/VideoNodes/Tests/_TestBase.cs index b2d59a8b..884ce04f 100644 --- a/VideoNodes/Tests/_TestBase.cs +++ b/VideoNodes/Tests/_TestBase.cs @@ -66,6 +66,7 @@ public abstract class TestBase protected string TestFile_MovText_Mp4 => Path.Combine(TestPath, "movtext.mp4"); protected string TestFile_BasicMkv => Path.Combine(TestPath, "basic.mkv"); protected string TestFile_Tag => Path.Combine(TestPath, "tag.mp4"); + protected string TestFile_Sitcom => Path.Combine(TestPath, "sitcom.mkv"); protected string TestFile_Pgs => Path.Combine(TestPath, "pgs.mkv"); protected string TestFile_Font => Path.Combine(TestPath, "font.mkv"); protected string TestFile_DefaultSub => Path.Combine(TestPath, "default-sub.mkv"); diff --git a/VideoNodes/VideoNodes/EncodingNode.cs b/VideoNodes/VideoNodes/EncodingNode.cs index 1f9603bd..597c08b8 100644 --- a/VideoNodes/VideoNodes/EncodingNode.cs +++ b/VideoNodes/VideoNodes/EncodingNode.cs @@ -65,6 +65,7 @@ namespace FileFlows.VideoNodes Encoder = new FFMpegEncoder(ffmpegExe, args.Logger); Encoder.AtTime += AtTimeEvent; + Encoder.OnStatChange += EncoderOnOnStatChange; if (string.IsNullOrEmpty(outputFile)) outputFile = System.IO.Path.Combine(args.TempPath, Guid.NewGuid() + "." + extension); @@ -90,6 +91,7 @@ namespace FileFlows.VideoNodes SetVideoInfo(args, videoInfo, this.Variables ?? new Dictionary()); } Encoder.AtTime -= AtTimeEvent; + Encoder.OnStatChange -= EncoderOnOnStatChange; Encoder = null; output = success.output; return success.successs; @@ -114,6 +116,9 @@ namespace FileFlows.VideoNodes Args.PartPercentageUpdate(percent); } + private void EncoderOnOnStatChange(string name, object value) + => Args.AdditionalInfoRecorder?.Invoke(name, value, new TimeSpan(0, 1, 0)); + public string CheckVideoCodec(string ffmpeg, string vidparams) { if (string.IsNullOrEmpty(vidparams))