diff --git a/VideoNodes/FfmpegBuilderNodes/Video/FfmpegBuilderAspectRatio.cs b/VideoNodes/FfmpegBuilderNodes/Video/FfmpegBuilderAspectRatio.cs new file mode 100644 index 00000000..0ea481f7 --- /dev/null +++ b/VideoNodes/FfmpegBuilderNodes/Video/FfmpegBuilderAspectRatio.cs @@ -0,0 +1,229 @@ +namespace FileFlows.VideoNodes.FfmpegBuilderNodes; + +/// +/// A node that forces the aspect ratio of a video using FFmpeg. +/// Provides options to stretch, add black bars (letterbox), crop, or use custom dimensions. +/// +public class FfmpegBuilderAspectRatio : FfmpegBuilderNode +{ + /// + public override int Outputs => 2; + /// + public override string HelpUrl => "https://fileflows.com/docs/plugins/video-nodes/ffmpeg-builder/aspect-ratio"; + /// + public override string Icon => "far fa-percent"; + + /// + /// The desired aspect ratio for the video. Supports standard options like 16:9, 4:3, 21:9, or custom dimensions. + /// + [Select(nameof(AspectRatioOptions), 1)] + public string AspectRatio { get; set; } + + /// + /// The mode of adjustment to enforce the aspect ratio. Options include stretching, adding black bars, cropping, or using custom dimensions. + /// + [Select(nameof(AdjustmentModeOptions), 2)] + public string AdjustmentMode { get; set; } = "Stretch"; + + /// + /// Custom width when using the custom aspect ratio option. + /// Used if is set to "Custom". + /// + [Range(1, int.MaxValue)] + [ConditionEquals(nameof(AspectRatio), "Custom")] + public int CustomWidth { get; set; } + + /// + /// Custom height when using the custom aspect ratio option. + /// Used if is set to "Custom". + /// + [Range(1, int.MaxValue)] + [ConditionEquals(nameof(AspectRatio), "Custom")] + public int CustomHeight { get; set; } + + /// + /// A list of standard aspect ratio options (16:9, 4:3, 21:9, or Custom). + /// + private static List _AspectRatioOptions; + public static List AspectRatioOptions + { + get + { + if (_AspectRatioOptions == null) + { + _AspectRatioOptions = new List + { + new() { Value = "16:9", Label = "16:9" }, + new() { Value = "4:3", Label = "4:3" }, + new() { Value = "21:9", Label = "21:9" }, + new() { Value = "Custom", Label = "Custom" } + }; + } + return _AspectRatioOptions; + } + } + + /// + /// A list of adjustment modes: Stretch, Add Black Bars (Letterbox), Crop, or Custom. + /// + private static List _AdjustmentModeOptions; + public static List AdjustmentModeOptions + { + get + { + if (_AdjustmentModeOptions == null) + { + _AdjustmentModeOptions = new List + { + new() { Value = "Stretch", Label = "Stretch to Fit" }, + new() { Value = "AddBlackBars", Label = "Add Black Bars (Letterbox)" }, + new() { Value = "Crop", Label = "Crop to Fit" }, + }; + } + return _AdjustmentModeOptions; + } + } + + /// + /// Executes the FFmpeg aspect ratio adjustment based on the selected aspect ratio and adjustment mode. + /// + /// The node parameters passed to the FFmpeg builder. + /// + /// 1 if the aspect ratio was changed successfully, + /// 2 if the video was already in the desired aspect ratio and no change was needed, + /// -1 if there was an error. + /// + public override int Execute(NodeParameters args) + { + var videoInfo = GetVideoInfo(args); + if (videoInfo == null || videoInfo.VideoStreams?.Any() != true) + { + args.FailureReason = "No valid video stream found."; + args.Logger?.ELog(args.FailureReason); + return -1; + } + + int videoWidth = videoInfo.VideoStreams[0].Width; + int videoHeight = videoInfo.VideoStreams[0].Height; + double currentAspectRatio = (double)videoWidth / videoHeight; + + args.Logger?.ILog($"Current video dimensions: {videoWidth}x{videoHeight}, Aspect Ratio: {currentAspectRatio:F2}"); + + // Check for custom aspect ratio + double targetAspectRatio; + if (AspectRatio == "Custom") + { + targetAspectRatio = (double)CustomWidth / CustomHeight; + args.Logger?.ILog($"Target custom aspect ratio: {CustomWidth}:{CustomHeight} = {targetAspectRatio:F2}"); + + if (IsAlreadyDesiredAspectRatio(currentAspectRatio, targetAspectRatio)) + { + args.Logger?.ILog("The video is already in the desired custom aspect ratio."); + return 2; + } + } + else + { + string[] aspectParts = AspectRatio.Split(':'); + targetAspectRatio = (double)int.Parse(aspectParts[0]) / int.Parse(aspectParts[1]); + args.Logger?.ILog($"Target aspect ratio: {AspectRatio} = {targetAspectRatio:F2}"); + + // Check if the video is already in the desired aspect ratio + if (IsAlreadyDesiredAspectRatio(currentAspectRatio, targetAspectRatio)) + { + args.Logger?.ILog("The video is already in the desired aspect ratio."); + return 2; + } + } + + // Generate the filter based on adjustment mode + string filter = AdjustmentMode switch + { + "Stretch" => $"scale={videoWidth}:{(int)(videoWidth / targetAspectRatio)}:flags=lanczos", + "AddBlackBars" => AddBlackBars(videoWidth, videoHeight, targetAspectRatio), + "Crop" => CropToFit(videoWidth, videoHeight, targetAspectRatio), + _ => null + }; + + if (filter == null) + { + args.FailureReason = "Failed to generate a filter based on the adjustment mode."; + args.Logger?.ELog(args.FailureReason); + return -1; + } + + args.Logger?.ILog($"Applying filter: {filter}"); + Model.VideoStreams[0].Filter.AddRange(new[] { filter }); + return 1; + } + + /// + /// Determines if the video is already in the desired aspect ratio within a small tolerance. + /// + /// The current aspect ratio of the video. + /// The desired target aspect ratio. + /// True if the video is already within the target aspect ratio tolerance. + private bool IsAlreadyDesiredAspectRatio(double currentAspectRatio, double targetAspectRatio) + { + const double tolerance = 0.01; // 1% tolerance for aspect ratio matching + bool result = Math.Abs(currentAspectRatio - targetAspectRatio) < tolerance; + Args?.Logger?.ILog($"Checking aspect ratio: current={currentAspectRatio:F2}, target={targetAspectRatio:F2}, result={result}"); + return result; + } + + /// + /// Adds black bars to the video (letterbox) to fit the target aspect ratio. + /// + /// The width of the original video. + /// The height of the original video. + /// The desired aspect ratio. + /// A string representing the FFmpeg filter to add black bars. + private string AddBlackBars(int width, int height, double targetAspectRatio) + { + double currentAspectRatio = (double)width / height; + Args?.Logger?.ILog($"Adding black bars: width={width}, height={height}, targetAspectRatio={targetAspectRatio:F2}"); + + if (currentAspectRatio > targetAspectRatio) + { + // Add vertical black bars + int paddedHeight = (int)(width / targetAspectRatio); + Args?.Logger?.ILog($"Adding vertical black bars, padded height={paddedHeight}"); + return $"scale={width}:-2, pad={width}:{paddedHeight}:(ow-iw)/2:(oh-ih)/2"; + } + else + { + // Add horizontal black bars + int paddedWidth = (int)(height * targetAspectRatio); + Args?.Logger?.ILog($"Adding horizontal black bars, padded width={paddedWidth}"); + return $"scale=-2:{height}, pad={paddedWidth}:{height}:(ow-iw)/2:(oh-ih)/2"; + } + } + + /// + /// Crops the video to fit the desired aspect ratio, discarding part of the frame. + /// + /// The width of the original video. + /// The height of the original video. + /// The desired aspect ratio. + /// A string representing the FFmpeg filter to crop the video. + private string CropToFit(int width, int height, double targetAspectRatio) + { + double currentAspectRatio = (double)width / height; + Args?.Logger?.ILog($"Cropping video: width={width}, height={height}, targetAspectRatio={targetAspectRatio:F2}"); + + if (currentAspectRatio > targetAspectRatio) + { + // Crop width + int croppedWidth = (int)(height * targetAspectRatio); + Args?.Logger?.ILog($"Cropping width, new width={croppedWidth}"); + return $"crop={croppedWidth}:{height}"; + } + else + { + // Crop height + int croppedHeight = (int)(width / targetAspectRatio); + Args?.Logger?.ILog($"Cropping height, new height={croppedHeight}"); + return $"crop={width}:{croppedHeight}"; + } + } +} diff --git a/VideoNodes/Tests/FfmpegBuilderTests/FFmpegBuilder_AspectRatioTests.cs b/VideoNodes/Tests/FfmpegBuilderTests/FFmpegBuilder_AspectRatioTests.cs new file mode 100644 index 00000000..d5a24f28 --- /dev/null +++ b/VideoNodes/Tests/FfmpegBuilderTests/FFmpegBuilder_AspectRatioTests.cs @@ -0,0 +1,146 @@ +using FileFlows.VideoNodes.FfmpegBuilderNodes; +using FileFlows.VideoNodes.FfmpegBuilderNodes.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using VideoNodes.Tests; + +namespace FileFlows.VideoNodes.Tests.FfmpegBuilderTests; + +/// +/// Tests for FFmpeg Builder: Aspect Ratio +/// +[TestClass] +[TestCategory("Slow")] +public class FFmpegBuilder_AspectRatioTests : VideoTestBase +{ + NodeParameters args; + + /// + /// Sets up the test environment before each test. + /// Initializes video parameters and executes the video file setup. + /// + private void InitVideo(string file) + { + args = GetVideoNodeParameters(file); + VideoFile vf = new VideoFile(); + vf.PreExecute(args); + vf.Execute(args); + + FfmpegBuilderStart ffStart = new(); + ffStart.PreExecute(args); + Assert.AreEqual(1, ffStart.Execute(args)); + } + + /// + /// Checks if the video aspect ratio matches the provided width and height ratio. + /// Logs the results and asserts conditions. + /// + /// The expected width ratio. + /// The expected height ratio. + private void CheckAspectRatio(int expectedWidth, int expectedHeight) + { + var videoInfo = (VideoInfo)args.Parameters[VideoNode.VIDEO_INFO]; + Assert.IsNotNull(videoInfo, "Video info is null."); + + var videoStream = videoInfo.VideoStreams[0]; + int width = videoStream.Width; + int height = videoStream.Height; + + double actualAspectRatio = (double)width / height; + double expectedAspectRatio = (double)expectedWidth / expectedHeight; + Logger.ILog($"Current dimensions: {width}x{height}"); + + Logger.ILog($"Current aspect ratio: {actualAspectRatio:F4}, Expected: {expectedAspectRatio:F4}"); + Assert.AreEqual(expectedAspectRatio, actualAspectRatio, 0.01, "Aspect ratio does not match the expected value."); + } + + /// + /// Executes the aspect ratio test with the specified parameters. + /// + /// The desired aspect ratio (e.g., "4:3", "16:9"). + /// The adjustment mode to use (e.g., "Crop"). + /// The expected width ratio. + /// The expected height ratio. + /// The video file to test. + private void ExecuteAspectRatioTest(string aspectRatio, string adjustmentMode, int expectedWidth, int expectedHeight, string? file = null) + { + file ??= Video4by7; + InitVideo(file); + + var ffAspectRatio = new FfmpegBuilderAspectRatio + { + AspectRatio = aspectRatio, + AdjustmentMode = adjustmentMode + }; + + if (aspectRatio == "Custom") + { + ffAspectRatio.CustomWidth = expectedWidth; // Set custom width + ffAspectRatio.CustomHeight = expectedHeight; // Set custom height + } + + ffAspectRatio.PreExecute(args); + ffAspectRatio.Execute(args); + + var ffExecutor = new FfmpegBuilderExecutor(); + ffExecutor.PreExecute(args); + int result = ffExecutor.Execute(args); + + Assert.AreEqual(1, result); + CheckAspectRatio(expectedWidth, expectedHeight); + } + + /// + /// Test to verify the video scaler with a 4:3 aspect ratio using cropping. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_4by3_Crop() + { + ExecuteAspectRatioTest("4:3", "Crop", 4, 3); + } + + /// + /// Test to verify the video scaler with a 16:9 aspect ratio using cropping. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_16by9_Crop() + { + ExecuteAspectRatioTest("16:9", "Crop", 16, 9); + } + + /// + /// Test to verify the video scaler with a 16:9 aspect ratio using stretching. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_16by9_Stretch() + { + ExecuteAspectRatioTest("16:9", "Stretch", 16, 9, file: Video4by3); + } + + /// + /// Test to verify the video scaler with a 16:9 aspect ratio using black bars. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_16by9_AddBlackBars() + { + ExecuteAspectRatioTest("16:9", "AddBlackBars", 16, 9, file: Video4by3); + } + + /// + /// Test to verify the video scaler with a 21:9 aspect ratio using cropping. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_21by9_Crop() + { + ExecuteAspectRatioTest("21:9", "Crop", 21, 9); + } + + /// + /// Test to verify the video scaler with a custom aspect ratio using cropping. + /// Modify as needed to fit the custom requirements. + /// + [TestMethod] + public void FfmpegBuilder_AspectRatio_CustomAspectRatio_Crop() + { + ExecuteAspectRatioTest("Custom", "Crop", 4, 5); + } +} \ No newline at end of file diff --git a/VideoNodes/Tests/Resources/video4by3.mp4 b/VideoNodes/Tests/Resources/video4by3.mp4 new file mode 100644 index 00000000..26fb8b24 Binary files /dev/null and b/VideoNodes/Tests/Resources/video4by3.mp4 differ diff --git a/VideoNodes/Tests/Resources/video4by7.mkv b/VideoNodes/Tests/Resources/video4by7.mkv new file mode 100644 index 00000000..80d30c56 Binary files /dev/null and b/VideoNodes/Tests/Resources/video4by7.mkv differ diff --git a/VideoNodes/Tests/_VideoTestBase.cs b/VideoNodes/Tests/_VideoTestBase.cs index 602b2afe..1e43e7fb 100644 --- a/VideoNodes/Tests/_VideoTestBase.cs +++ b/VideoNodes/Tests/_VideoTestBase.cs @@ -31,6 +31,16 @@ public abstract class VideoTestBase : TestBase /// protected static readonly string VideoMkvHevc = ResourcesTestFilesDir + "/hevc.mkv"; + /// + /// Video 4:7 MKV file + /// + protected static readonly string Video4by7 = ResourcesTestFilesDir + "/video4by7.mkv"; + + /// + /// Video 4:3 mp4 file + /// + protected static readonly string Video4by3 = ResourcesTestFilesDir + "/video4by3.mp4"; + /// /// Video Corrupt file /// diff --git a/VideoNodes/i18n/de.json b/VideoNodes/i18n/de.json index ecc31939..898a3c0a 100644 --- a/VideoNodes/i18n/de.json +++ b/VideoNodes/i18n/de.json @@ -118,6 +118,23 @@ "Strictness-Help": "Ermöglicht es Ihnen, die Strenge von FFmpeg einzustellen. Für die meisten Benutzer sollte diese Einstellung auf 'Experimentell' belassen werden." } }, + "FfmpegBuilderAspectRatio": { + "Label": "FFMPEG Builder: Seitenverhältnis", + "Outputs": { + "1": "Seitenverhältnis wurde erfolgreich geändert", + "2": "Video war bereits im gewünschten Seitenverhältnis" + }, + "Fields": { + "AspectRatio": "Seitenverhältnis", + "AspectRatio-Help": "Wählen Sie das gewünschte Seitenverhältnis für das Video aus. Verfügbare Optionen sind Standard-Seitenverhältnisse (16:9, 4:3, 21:9) oder ein benutzerdefiniertes Verhältnis.", + "CustomWidth": "Benutzerdefinierte Breite", + "CustomWidth-Help": "Geben Sie die Breite an, die verwendet werden soll, wenn die Option für ein benutzerdefiniertes Seitenverhältnis ausgewählt ist.", + "CustomHeight": "Benutzerdefinierte Höhe", + "CustomHeight-Help": "Geben Sie die Höhe an, die verwendet werden soll, wenn die Option für ein benutzerdefiniertes Seitenverhältnis ausgewählt ist.", + "AdjustmentMode": "Anpassungsmodus", + "AdjustmentMode-Help": "Wählen Sie, wie das Seitenverhältnis erzwungen werden soll. Optionen umfassen das Strecken des Videos, das Hinzufügen von schwarzen Balken (Letterbox) oder das Zuschneiden des Videos, um das Seitenverhältnis anzupassen." + } + }, "FfmpegBuilderAddInputFile": { "Label": "FFMPEG-Builder: Eingabedatei hinzufügen", "Outputs": { diff --git a/VideoNodes/i18n/en.json b/VideoNodes/i18n/en.json index 5b59b04b..79dfca1e 100644 --- a/VideoNodes/i18n/en.json +++ b/VideoNodes/i18n/en.json @@ -157,6 +157,23 @@ "UseSourceDirectory-Help": "If checked the original source directory will be searched, otherwise the working directory will be used." } }, + "FfmpegBuilderAspectRatio": { + "Label": "FFMPEG Builder: Aspect Ratio", + "Outputs": { + "1": "Aspect ratio was changed successfully", + "2": "Video was already in the desired aspect ratio" + }, + "Fields": { + "AspectRatio": "Aspect Ratio", + "AspectRatio-Help": "Select the desired aspect ratio for the video. Available options include standard aspect ratios (16:9, 4:3, 21:9) or a custom ratio.", + "CustomWidth": "Custom Width", + "CustomWidth-Help": "Specify the width to use when selecting the custom aspect ratio option.", + "CustomHeight": "Custom Height", + "CustomHeight-Help": "Specify the height to use when selecting the custom aspect ratio option.", + "AdjustmentMode": "Adjustment Mode", + "AdjustmentMode-Help": "Choose how the aspect ratio should be enforced. Options include stretching the video, adding black bars (letterbox), or cropping the video to fit the aspect ratio." + } + }, "FfmpegBuilderAudioAddTrack": { "Label": "FFMPEG Builder: Audio Add Track", "Outputs": {