565 lines
21 KiB
C#
565 lines
21 KiB
C#
using System.Text.RegularExpressions;
|
||
using SharpDX;
|
||
using SharpDX.Direct3D11;
|
||
using SharpDX.IO;
|
||
using SharpDX.WIC;
|
||
using T3.Core.Animation;
|
||
using T3.Core.Settings;
|
||
using T3.Core.Utils;
|
||
using Utilities = T3.Core.Utils.Utilities;
|
||
|
||
namespace Examples.testing;
|
||
|
||
[Guid("f92a1144-55d9-4d8e-bfa6-f80665800e25")]
|
||
internal sealed class VisualTest : Instance<VisualTest>
|
||
{
|
||
[Output(Guid = "0B56A8D8-F7F1-4FEA-A153-B529F49B989D")]
|
||
public readonly Slot<string> Result = new();
|
||
|
||
public VisualTest()
|
||
{
|
||
Result.UpdateAction = Update;
|
||
}
|
||
|
||
private void Update(EvaluationContext context)
|
||
{
|
||
var isExecution = context.IntVariables.TryGetValue(TestFrameKey, out var testFrame);
|
||
if (!isExecution)
|
||
{
|
||
if (!_complainedOnce)
|
||
{
|
||
Log.Warning("Needs to be run with [ExecuteTests]", this);
|
||
_complainedOnce = true;
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
_complainedOnce = false;
|
||
|
||
if (!context.ObjectVariables.TryGetValue(TestResultKey, out var obj2) || obj2 is not List<string> testResultList)
|
||
{
|
||
Log.Warning("Test Results missing?", this);
|
||
return;
|
||
}
|
||
|
||
if (!context.ObjectVariables.TryGetValue(TestActionKey, out var obj) || obj is not string testAction)
|
||
{
|
||
Log.Warning("Test Action missing?", this);
|
||
return;
|
||
}
|
||
|
||
var contextAction = testAction switch
|
||
{
|
||
TestCommandId => States.Testing,
|
||
UpdateReferencesCommandId => States.UpdatingReferences,
|
||
_ => States.Unknown,
|
||
};
|
||
|
||
if (contextAction == States.Unknown)
|
||
{
|
||
Log.Warning($"Unknown action {testAction}", this);
|
||
return;
|
||
}
|
||
|
||
if (contextAction != _state)
|
||
{
|
||
Log.Debug($"Switching to {contextAction}", this);
|
||
UpdateTestParams(context);
|
||
_state = contextAction;
|
||
}
|
||
|
||
switch (_state)
|
||
{
|
||
case States.Testing:
|
||
ConductTests(context, testResultList);
|
||
break;
|
||
|
||
case States.UpdatingReferences:
|
||
UpdateAndSaveReferences(context, testResultList);
|
||
break;
|
||
}
|
||
}
|
||
|
||
|
||
private const string TestCommandId= "Test";
|
||
private const string UpdateReferencesCommandId= "UpdateReferences";
|
||
|
||
/// <summary>
|
||
/// We only update test params on state change so we can reuse the same settings during multiple tests.
|
||
/// This can be useful to iterate tests over multiple frames (e.g. while waiting at seeking within a video).
|
||
/// </summary>
|
||
private void UpdateTestParams(EvaluationContext context)
|
||
{
|
||
_testIndex = 0;
|
||
_stepCount = StepCount.GetValue(context).Clamp(1, 100);
|
||
_threshold = Threshold.GetValue(context);
|
||
_timeRange = TimeRange.GetValue(context);
|
||
_warmUpSteps = WarmUpStepCount.GetValue(context).Clamp(0, 1000);
|
||
var res=Resolution.GetValue(context);
|
||
_defaultResolution = new Int2(res.X.Clamp(1, 16383),
|
||
res.Y.Clamp(1, 16383));
|
||
}
|
||
|
||
private bool TryUpdateImage(EvaluationContext context, int index, out float usedTime, out Texture2D image)
|
||
{
|
||
ResourceManager.Device.ImmediateContext.Flush();
|
||
|
||
usedTime = 0;
|
||
var previousKeyframeTime = context.LocalTime;
|
||
var previousEffectTime = context.LocalFxTime;
|
||
var previousResolution = context.RequestedResolution;
|
||
var previousGizmo = context.ShowGizmos;
|
||
context.ShowGizmos = GizmoVisibility.Off;
|
||
|
||
var f = _stepCount <= 1 ? 0.5f : (float)index / _stepCount;
|
||
Playback.Current.IsRenderingToFile = true;
|
||
var stepStep = MathUtils.Lerp(_timeRange.X, _timeRange.Y, f);
|
||
image = null;
|
||
for (int midStepIndex = 0; midStepIndex <= _warmUpSteps; midStepIndex++)
|
||
{
|
||
var subTime = _warmUpSteps <= 1 ? 0 : (float)midStepIndex / _warmUpSteps;
|
||
var time = stepStep + subTime;
|
||
usedTime = time;
|
||
|
||
context.LocalTime = time;
|
||
context.LocalFxTime = time;
|
||
context.RequestedResolution = _defaultResolution;
|
||
|
||
DirtyFlag.GlobalInvalidationTick++;
|
||
Image.InvalidateGraph();
|
||
Image.DirtyFlag.ForceInvalidate();
|
||
|
||
image = Image.GetValue(context);
|
||
}
|
||
|
||
context.ShowGizmos = previousGizmo;
|
||
context.LocalTime = previousKeyframeTime;
|
||
context.LocalFxTime = previousEffectTime;
|
||
context.RequestedResolution = previousResolution;
|
||
return !Playback.OpNotReady;
|
||
}
|
||
|
||
private enum States
|
||
{
|
||
Waiting,
|
||
UpdatingReferences,
|
||
Testing,
|
||
Completed,
|
||
Unknown,
|
||
}
|
||
|
||
|
||
private void UpdateAndSaveReferences(EvaluationContext context, List<string> testResultBuilder)
|
||
{
|
||
for (; _testIndex < _stepCount; _testIndex++)
|
||
{
|
||
var ready= TryUpdateImage(context, _testIndex, out _, out var image);
|
||
if (!ready)
|
||
{
|
||
Log.Debug(" waiting for op to be ready...", this);
|
||
return;
|
||
}
|
||
var filepath = GetReferenceFilepath(_testIndex);
|
||
Log.Debug("Saving image " + filepath, this);
|
||
|
||
SaveTexture(image, filepath);
|
||
testResultBuilder.Add("saved " + filepath);
|
||
}
|
||
|
||
_state = States.Completed;
|
||
Playback.Current.IsRenderingToFile = false;
|
||
}
|
||
|
||
|
||
private void ConductTests(EvaluationContext context, List<string> testResult)
|
||
{
|
||
SharpDX.Direct3D11.Texture2D diffColorImage = null;
|
||
for (; _testIndex < _stepCount; _testIndex++)
|
||
{
|
||
var referenceFilepath = GetReferenceFilepath(_testIndex);
|
||
var testName = GetTestName(_testIndex);
|
||
|
||
if (!TryLoadTextureFromFile(ResourceManager.Device, referenceFilepath, out var referenceImage))
|
||
{
|
||
Log.Warning($"Can't find image... {referenceFilepath}");
|
||
continue;
|
||
}
|
||
|
||
var ready = TryUpdateImage(context, _testIndex, out var time, out var image);
|
||
if (!ready)
|
||
{
|
||
Log.Debug(" waiting for op to be ready...", this);
|
||
return;
|
||
}
|
||
|
||
if (image == null)
|
||
{
|
||
Log.Debug(" failed to get image...", this);
|
||
return;
|
||
}
|
||
|
||
var currentWithCpuAccess = _textureBgraReadAccess.ConvertToCpuReadableBgra(image);
|
||
var deviation = CompareImage(currentWithCpuAccess, referenceImage);
|
||
var failPath = GetReferenceFilepath(_testIndex, "FAIL");
|
||
|
||
var timeLabel = time == 0 ? string.Empty : $"@{time:0.00}";
|
||
if (deviation < _threshold)
|
||
{
|
||
if (File.Exists(failPath))
|
||
{
|
||
File.Delete(failPath);
|
||
}
|
||
|
||
testResult.Add($"{testName} {timeLabel}: PASSED");
|
||
}
|
||
else
|
||
{
|
||
SaveTexture(image, GetReferenceFilepath(_testIndex, "FAIL"));
|
||
testResult.Add($"{testName} {timeLabel}: FAILED ({deviation:0.00} > {_threshold})");
|
||
}
|
||
|
||
Utilities.Dispose(ref diffColorImage);
|
||
}
|
||
_state = States.Completed;
|
||
Playback.Current.IsRenderingToFile = false;
|
||
}
|
||
|
||
private static void SaveTexture(Texture2D texture, string filePath)
|
||
{
|
||
_textureBgraReadAccess.InitiateConvertAndReadBack(texture, WriteTextureToFile, filePath);
|
||
}
|
||
|
||
private string GetTestName(int index)
|
||
{
|
||
List<string> parts = [];
|
||
|
||
parts.Add(GetCompositionName());
|
||
|
||
if (!string.IsNullOrEmpty(SymbolChild.Name))
|
||
{
|
||
parts.Add(SymbolChild.Name);
|
||
}
|
||
else
|
||
{
|
||
parts.Add("Test_" + SymbolChildId.ShortenGuid(5));
|
||
}
|
||
|
||
parts.Add($"{index:00}");
|
||
|
||
return string.Join(" / ", parts);
|
||
}
|
||
|
||
private string GetReferenceFilepath(int index, string suffix =null)
|
||
{
|
||
List<string> parts = [];
|
||
|
||
if (!string.IsNullOrEmpty(SymbolChild.Name))
|
||
{
|
||
parts.Add(Regex.Replace(SymbolChild.Name, @"[^a-zA-Z0-9_]", "_"));
|
||
}
|
||
|
||
parts.Add(SymbolChildId.ShortenGuid(5));
|
||
|
||
parts.Add($"{index:00}");
|
||
|
||
if(!string.IsNullOrEmpty(suffix))
|
||
parts.Add(suffix);
|
||
|
||
var baseName = string.Join('_', parts);
|
||
baseName += ".png";
|
||
|
||
return Path.Join( FileLocations.TestReferencesFolder, GetCompositionName(), baseName);
|
||
}
|
||
|
||
private string GetCompositionName()
|
||
{
|
||
var compositionName = !string.IsNullOrEmpty(Parent?.Symbol.Name)
|
||
? Parent.Symbol.Name
|
||
: "Misc";
|
||
return compositionName;
|
||
}
|
||
|
||
private static bool TryLoadTextureFromFile(SharpDX.Direct3D11.Device device, string filePath, out SharpDX.Direct3D11.Texture2D image)
|
||
{
|
||
if (!File.Exists(filePath))
|
||
{
|
||
image = null;
|
||
return false;
|
||
}
|
||
|
||
var imagingFactory = new ImagingFactory();
|
||
|
||
// Decode the image
|
||
var decoder = new BitmapDecoder(imagingFactory, filePath, DecodeOptions.CacheOnDemand);
|
||
var frame = decoder.GetFrame(0);
|
||
|
||
// Convert to 32bpp RGBA
|
||
var converter = new FormatConverter(imagingFactory);
|
||
converter.Initialize(frame, PixelFormat.Format32bppRGBA);
|
||
|
||
int width = converter.Size.Width;
|
||
int height = converter.Size.Height;
|
||
|
||
// Copy pixels into a buffer
|
||
var stride = width * 4;
|
||
var buffer = new DataStream(height * stride, true, true);
|
||
converter.CopyPixels(stride, buffer);
|
||
|
||
// Create texture description
|
||
var texDesc = new Texture2DDescription
|
||
{
|
||
Width = width,
|
||
Height = height,
|
||
ArraySize = 1,
|
||
BindFlags = BindFlags.ShaderResource,
|
||
Usage = ResourceUsage.Immutable,
|
||
CpuAccessFlags = CpuAccessFlags.None,
|
||
Format = Format.R8G8B8A8_UNorm,
|
||
MipLevels = 1,
|
||
OptionFlags = ResourceOptionFlags.None,
|
||
SampleDescription = new SampleDescription(1, 0)
|
||
};
|
||
|
||
// Define initial data
|
||
var dataBox = new DataBox(buffer.DataPointer, stride, 0);
|
||
var dataBoxes = new[] { dataBox };
|
||
var texture = new SharpDX.Direct3D11.Texture2D(device, texDesc, dataBoxes);
|
||
|
||
// Cleanup
|
||
buffer.Dispose();
|
||
converter.Dispose();
|
||
frame.Dispose();
|
||
decoder.Dispose();
|
||
imagingFactory.Dispose();
|
||
image = texture;
|
||
return true;
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Sadly, we have to deal with BRGA vs RGBA because we convert the current image to brga on the GPU
|
||
/// to speedup writing as PNG.
|
||
/// </remarks>
|
||
private float CompareImage(SharpDX.Direct3D11.Texture2D currentBgraWithCpuAccess,
|
||
SharpDX.Direct3D11.Texture2D reference)
|
||
{
|
||
try
|
||
{
|
||
if (currentBgraWithCpuAccess == null || reference == null ||
|
||
currentBgraWithCpuAccess.Description.Width != reference.Description.Width ||
|
||
currentBgraWithCpuAccess.Description.Height != reference.Description.Height)
|
||
{
|
||
Log.Warning($"{GetReferenceFilepath(0)} incorrect size? {currentBgraWithCpuAccess?.Description.Width}x{currentBgraWithCpuAccess?.Description.Height} vs {reference?.Description.Width}×{reference?.Description.Height}");
|
||
return -1f;
|
||
}
|
||
|
||
if (currentBgraWithCpuAccess.Description.Format != Format.B8G8R8A8_UNorm)
|
||
{
|
||
Log.Warning($"Source image format {currentBgraWithCpuAccess.Description.Format} is expected to be BGRA8");
|
||
return -1f;
|
||
}
|
||
|
||
|
||
var immediateContext = ResourceManager.Device.ImmediateContext;
|
||
var referenceDesc = new Texture2DDescription()
|
||
{
|
||
BindFlags = BindFlags.None,
|
||
Format = reference.Description.Format,
|
||
Width = reference.Description.Width,
|
||
Height = reference.Description.Height,
|
||
MipLevels = reference.Description.MipLevels,
|
||
SampleDescription = new SampleDescription(1, 0),
|
||
Usage = ResourceUsage.Staging,
|
||
OptionFlags = ResourceOptionFlags.None,
|
||
CpuAccessFlags = CpuAccessFlags.Read,
|
||
ArraySize = 1
|
||
};
|
||
var referenceWithCpuAccess = new SharpDX.Direct3D11.Texture2D(ResourceManager.Device, referenceDesc);
|
||
immediateContext.CopyResource(reference, referenceWithCpuAccess);
|
||
ResourceManager.Device.ImmediateContext.Flush(); // Ensure the copy is complete
|
||
|
||
|
||
var currentDataBox =
|
||
immediateContext.MapSubresource(currentBgraWithCpuAccess, 0, 0, MapMode.Read, SharpDX.Direct3D11.MapFlags.None, out var currentStream);
|
||
currentStream.Position = 0;
|
||
|
||
var refDataBox =
|
||
immediateContext.MapSubresource(referenceWithCpuAccess, 0, 0, MapMode.Read, SharpDX.Direct3D11.MapFlags.None, out var referenceStream);
|
||
referenceStream.Position = 0;
|
||
|
||
|
||
double deviation = 0;
|
||
for (int y = 0; y < currentBgraWithCpuAccess.Description.Height; ++y)
|
||
{
|
||
for (int x = 0; x < currentBgraWithCpuAccess.Description.Width; ++x)
|
||
{
|
||
var currentBgra = new Color4(currentStream.Read<Int32>());
|
||
var referenceRgba = new Color4(referenceStream.Read<Int32>());
|
||
deviation += Math.Abs(currentBgra.Alpha - referenceRgba.Alpha)
|
||
+ Math.Abs(currentBgra.Blue - referenceRgba.Red)
|
||
+ Math.Abs(currentBgra.Green - referenceRgba.Green)
|
||
+ Math.Abs(currentBgra.Red - referenceRgba.Blue);
|
||
}
|
||
|
||
currentStream.Position += currentDataBox.RowPitch - currentBgraWithCpuAccess.Description.Width * 4;
|
||
referenceStream.Position += refDataBox.RowPitch - currentBgraWithCpuAccess.Description.Width * 4;
|
||
}
|
||
|
||
deviation /= currentBgraWithCpuAccess.Description.Width * currentBgraWithCpuAccess.Description.Height;
|
||
|
||
immediateContext.UnmapSubresource(currentBgraWithCpuAccess, 0);
|
||
Utilities.Dispose(ref currentStream);
|
||
immediateContext.UnmapSubresource(referenceWithCpuAccess, 0);
|
||
Utilities.Dispose(ref referenceStream);
|
||
Utilities.Dispose(ref referenceWithCpuAccess);
|
||
return (float)deviation;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Log.Error(" Failed to compare " + ex.Message, this);
|
||
return -1;
|
||
}
|
||
}
|
||
|
||
|
||
private static void WriteTextureToFile(TextureBgraReadAccess.ReadRequestItem request)
|
||
{
|
||
var requestCpuAccessTexture = request.CpuAccessTexture;
|
||
var requestFilepath = request.Filepath;
|
||
|
||
var immediateContext = ResourceManager.Device.ImmediateContext;
|
||
if (requestCpuAccessTexture.IsDisposed)
|
||
{
|
||
Log.Debug("ScreenshotWriter: Texture was disposed before readback was complete");
|
||
return;
|
||
}
|
||
|
||
// Make sure target folder exists
|
||
if (string.IsNullOrEmpty(requestFilepath))
|
||
{
|
||
Log.Debug("ScreenshotWriter: Target folder missing?");
|
||
return;
|
||
}
|
||
|
||
var folder = Path.GetDirectoryName(requestFilepath);
|
||
if (!string.IsNullOrEmpty(folder))
|
||
{
|
||
//Log.Debug($"Creating {folder}...");
|
||
Directory.CreateDirectory(folder);
|
||
}
|
||
|
||
var dataBox = immediateContext.MapSubresource(requestCpuAccessTexture,
|
||
0,
|
||
0,
|
||
SharpDX.Direct3D11.MapMode.Read,
|
||
SharpDX.Direct3D11.MapFlags.None,
|
||
out var imageStream);
|
||
using var dataStream = imageStream;
|
||
|
||
var width = requestCpuAccessTexture.Description.Width;
|
||
var height = requestCpuAccessTexture.Description.Height;
|
||
var factory = new ImagingFactory();
|
||
|
||
WICStream stream;
|
||
try
|
||
{
|
||
stream = new WICStream(factory, requestFilepath, NativeFileAccess.Write);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Warning("Failed to export image: " + e.Message);
|
||
return;
|
||
}
|
||
|
||
BitmapEncoder encoder = new PngBitmapEncoder(factory);
|
||
encoder.Initialize(stream);
|
||
|
||
// Create a Frame encoder
|
||
var bitmapFrameEncode = new BitmapFrameEncode(encoder);
|
||
bitmapFrameEncode.Initialize();
|
||
bitmapFrameEncode.SetSize(width, height);
|
||
var formatId = PixelFormat.Format32bppRGBA;
|
||
bitmapFrameEncode.SetPixelFormat(ref formatId);
|
||
|
||
var rowStride = PixelFormat.GetStride(formatId, width);
|
||
var outBufferSize = height * rowStride;
|
||
var outDataStream = new DataStream(outBufferSize, true, true);
|
||
|
||
try
|
||
{
|
||
// Note: dataBox.RowPitch and outputStream.RowPitch can diverge if width is not divisible by 16.
|
||
for (var loopY = 0; loopY < height; loopY++)
|
||
{
|
||
imageStream.Position = (long)(loopY) * dataBox.RowPitch;
|
||
outDataStream.WriteRange(imageStream.ReadRange<byte>(rowStride));
|
||
}
|
||
|
||
// Copy the BGRA pixels from the buffer to the Wic Bitmap Frame encoder
|
||
bitmapFrameEncode.WritePixels(height, new DataRectangle(outDataStream.DataPointer, rowStride));
|
||
|
||
// Commit changes
|
||
bitmapFrameEncode.Commit();
|
||
encoder.Commit();
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Log.Error($"Screenshot internal image copy failed : {e.Message}");
|
||
}
|
||
finally
|
||
{
|
||
imageStream.Dispose();
|
||
outDataStream.Dispose();
|
||
bitmapFrameEncode.Dispose();
|
||
encoder.Dispose();
|
||
stream.Dispose();
|
||
}
|
||
}
|
||
|
||
private States _state = States.Waiting;
|
||
private int _testIndex;
|
||
|
||
private int _warmUpSteps;
|
||
private int _stepCount;
|
||
private float _threshold;
|
||
private Vector2 _timeRange;
|
||
private bool _complainedOnce;
|
||
|
||
// private const string ReferencesSubFolder = "tests";
|
||
// private const string ReferencesSourceFolder = ".tixl";
|
||
//
|
||
// #if DEBUG
|
||
// private const string ReferencesTargetFolder = FileLocations.AppSubFolder;
|
||
// #else
|
||
// private const string ReferencesTargetFolder = ".tixl";
|
||
// #endif
|
||
|
||
// private const int DefaultHeight = 120;
|
||
// private const int DefaultWidth = (int)(DefaultHeight * (16f / 9));
|
||
private Int2 _defaultResolution;
|
||
|
||
|
||
private const string TestFrameKey = "_TestFrame";
|
||
private const string TestResultKey = "_TestResult";
|
||
private const string TestActionKey = "_TestAction";
|
||
|
||
private static readonly TextureBgraReadAccess _textureBgraReadAccess = new(true);
|
||
|
||
[Input(Guid = "ed9887ca-5ee4-4fb7-a835-071de255a893")]
|
||
public readonly InputSlot<T3.Core.DataTypes.Texture2D> Image = new InputSlot<T3.Core.DataTypes.Texture2D>();
|
||
|
||
[Input(Guid = "9628A5C6-E731-4DF7-B49B-E617C75CFDA4")]
|
||
public readonly InputSlot<float> Threshold = new InputSlot<float>();
|
||
|
||
[Input(Guid = "59D30AB3-99D5-441A-A6E7-EB76278F6AC1")]
|
||
public readonly InputSlot<System.Numerics.Vector2> TimeRange = new InputSlot<System.Numerics.Vector2>();
|
||
|
||
[Input(Guid = "27ADB1E1-0704-4F1F-894D-BF38E3C8D982")]
|
||
public readonly InputSlot<int> StepCount = new InputSlot<int>();
|
||
|
||
[Input(Guid = "ADFE6FE4-7FA7-4F1C-9D66-1CBF9B383BA1")]
|
||
public readonly InputSlot<int> WarmUpStepCount = new InputSlot<int>();
|
||
|
||
[Input(Guid = "085CA93B-4167-444C-B3E0-A0628E93C633")]
|
||
public readonly InputSlot<T3.Core.DataTypes.Vector.Int2> Resolution = new InputSlot<T3.Core.DataTypes.Vector.Int2>();
|
||
|
||
} |