diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 70ba7b158..18ec4a470 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -63,6 +63,10 @@ add_executable(olive-gtest render_workerpool_ipc_test.cpp node_inputimmediate_test.cpp project_factory_test.cpp + render_processor_test.cpp + node_time_test.cpp + node_color_test.cpp + plugin_node_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/node_color_test.cpp b/tests/gtest/node_color_test.cpp new file mode 100644 index 000000000..ed0dd71cd --- /dev/null +++ b/tests/gtest/node_color_test.cpp @@ -0,0 +1,670 @@ +#include + +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/color/displaytransform/displaytransform.h" +#include "node/color/ociobase/ociobase.h" +#include "node/color/ociogradingtransformlinear/ociogradingtransformlinear.h" +#include "node/color/threewaycolor/threewaycolor.h" +#include "node/project.h" +#include "render/job/colortransformjob.h" +#include "render/job/shaderjob.h" +#include "render/texture.h" + +namespace +{ + +// A "dummy" texture has no renderer backend and is therefore safe to pass +// around in a headless, CPU-only test. +olive::TexturePtr MakeDummyTexture() +{ + return std::make_shared( + olive::VideoParams(16, 16, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); +} + +olive::NodeValueRow MakeTextureRow(const QString &input, + const olive::TexturePtr &tex) +{ + olive::NodeValueRow row; + row.insert(input, olive::NodeValue(olive::NodeValue::kTexture, tex)); + return row; +} + +olive::NodeValue Vec4Value(const QVector4D &v) +{ + return olive::NodeValue(olive::NodeValue::kVec4, v); +} + +olive::NodeValue BoolValue(bool b) +{ + return olive::NodeValue(olive::NodeValue::kBoolean, b); +} + +} // namespace + +// ----------------------------------------------------------------------------- +// OCIOBaseNode passthrough (exercised through DisplayTransformNode, which is +// a concrete OCIOBaseNode). Without a Project the base never has a processor, +// so Value() must pass the input texture through unchanged. +// ----------------------------------------------------------------------------- + +TEST(OCIOBaseNode, PassesTextureThroughWhenProcessorMissing) +{ + olive::DisplayTransformNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OCIOBaseNode::kTextureInput, tex); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::NodeValue out = table.Get(olive::NodeValue::kTexture); + EXPECT_EQ(out.type(), olive::NodeValue::kTexture); + EXPECT_EQ(out.toTexture(), tex); +} + +TEST(OCIOBaseNode, PushesNothingWhenTextureInputEmpty) +{ + olive::DisplayTransformNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +// ----------------------------------------------------------------------------- +// DisplayTransformNode +// ----------------------------------------------------------------------------- + +TEST(DisplayTransformNode, InputDefinitions) +{ + olive::DisplayTransformNode node; + + EXPECT_TRUE(node.HasInputWithID(olive::OCIOBaseNode::kTextureInput)); + EXPECT_TRUE(node.HasInputWithID(olive::DisplayTransformNode::kDisplayInput)); + EXPECT_TRUE(node.HasInputWithID(olive::DisplayTransformNode::kViewInput)); + EXPECT_TRUE( + node.HasInputWithID(olive::DisplayTransformNode::kDirectionInput)); + + EXPECT_EQ(node.GetInputDataType(olive::DisplayTransformNode::kDisplayInput), + olive::NodeValue::kCombo); + EXPECT_EQ(node.GetInputDataType(olive::DisplayTransformNode::kViewInput), + olive::NodeValue::kCombo); + EXPECT_EQ( + node.GetInputDataType(olive::DisplayTransformNode::kDirectionInput), + olive::NodeValue::kCombo); + + // Combo inputs are static UI choices: neither keyframable nor connectable. + EXPECT_FALSE( + node.IsInputKeyframable(olive::DisplayTransformNode::kDisplayInput)); + EXPECT_FALSE( + node.IsInputConnectable(olive::DisplayTransformNode::kDisplayInput)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::DisplayTransformNode::kViewInput)); + EXPECT_FALSE( + node.IsInputConnectable(olive::DisplayTransformNode::kViewInput)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::DisplayTransformNode::kDirectionInput)); + EXPECT_FALSE( + node.IsInputConnectable(olive::DisplayTransformNode::kDirectionInput)); + + EXPECT_EQ(node.GetStandardValue(olive::DisplayTransformNode::kDisplayInput) + .toInt(), + 0); + EXPECT_EQ(node.GetStandardValue(olive::DisplayTransformNode::kViewInput) + .toInt(), + 0); + EXPECT_EQ(node.GetStandardValue(olive::DisplayTransformNode::kDirectionInput) + .toInt(), + 0); + + EXPECT_EQ(node.GetEffectInputID(), olive::OCIOBaseNode::kTextureInput); +} + +TEST(DisplayTransformNode, Identity) +{ + olive::DisplayTransformNode node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.displaytransform")); + EXPECT_FALSE(node.Name().isEmpty()); + EXPECT_FALSE(node.Description().isEmpty()); + + ASSERT_EQ(node.Category().size(), 1); + EXPECT_EQ(int(node.Category().first()), int(olive::Node::kCategoryColor)); +} + +TEST(DisplayTransformNode, DisplayAndViewEmptyWithoutProject) +{ + olive::DisplayTransformNode node; + + // No ColorManager is attached, so display/view cannot be resolved. + EXPECT_TRUE(node.GetDisplay().isEmpty()); + EXPECT_TRUE(node.GetView().isEmpty()); + EXPECT_EQ(int(node.GetDirection()), int(olive::ColorProcessor::kNormal)); +} + +TEST(DisplayTransformNode, ResolvesDisplayAndViewInProject) +{ + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + olive::ColorManager *manager = project.color_manager(); + ASSERT_NE(manager, nullptr); + + const QStringList displays = manager->ListAvailableDisplays(); + ASSERT_FALSE(displays.isEmpty()); + + auto *node = new olive::DisplayTransformNode(); + node->setParent(&project); + + // Combo index 0 must resolve to the first available display/view. + EXPECT_EQ(node->GetDisplay(), displays.first()); + + const QStringList views = manager->ListAvailableViews(node->GetDisplay()); + ASSERT_FALSE(views.isEmpty()); + EXPECT_EQ(node->GetView(), views.first()); + + EXPECT_EQ(int(node->GetDirection()), int(olive::ColorProcessor::kNormal)); + + node->SetStandardValue(olive::DisplayTransformNode::kDirectionInput, 1); + EXPECT_EQ(int(node->GetDirection()), int(olive::ColorProcessor::kInverse)); +} + +TEST(DisplayTransformNode, RetranslateSetsInputNames) +{ + olive::DisplayTransformNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::OCIOBaseNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::DisplayTransformNode::kDisplayInput), + QStringLiteral("Display")); + EXPECT_EQ(node.GetInputName(olive::DisplayTransformNode::kViewInput), + QStringLiteral("View")); + EXPECT_EQ(node.GetInputName(olive::DisplayTransformNode::kDirectionInput), + QStringLiteral("Direction")); +} + +// ----------------------------------------------------------------------------- +// ThreeWayColorNode (beyond the factory/default coverage in color_lut_test.cpp) +// ----------------------------------------------------------------------------- + +TEST(ThreeWayColorNode, ShaderCodeLoadsFragmentResource) +{ + olive::ThreeWayColorNode node; + + const olive::ShaderCode code = + node.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("test"))); + + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.vert_code().isEmpty()); +} + +TEST(ThreeWayColorNode, ValueWithoutTexturePushesNothing) +{ + olive::ThreeWayColorNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(ThreeWayColorNode, ValuePushesShaderJobWithDefaultLumaCoefficients) +{ + olive::ThreeWayColorNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::ThreeWayColorNode::kTextureInput, tex); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = static_cast(out->job()); + ASSERT_NE(job, nullptr); + + const olive::NodeValueRow &values = job->GetValues(); + EXPECT_TRUE(values.contains(olive::ThreeWayColorNode::kTextureInput)); + ASSERT_TRUE(values.contains(olive::ThreeWayColorNode::kLumaCoefficientsInput)); + + // Without a project the node falls back to Rec. 709 luma coefficients. + const QVector3D coeffs = + values.value(olive::ThreeWayColorNode::kLumaCoefficientsInput).toVec3(); + EXPECT_NEAR(coeffs.x(), 0.2126f, 0.0001f); + EXPECT_NEAR(coeffs.y(), 0.7152f, 0.0001f); + EXPECT_NEAR(coeffs.z(), 0.0722f, 0.0001f); +} + +TEST(ThreeWayColorNode, AmountInputsDefaultToFull) +{ + olive::ThreeWayColorNode node; + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ThreeWayColorNode::kShadowsAmountInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ThreeWayColorNode::kMidtonesAmountInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ThreeWayColorNode::kHighlightsAmountInput) + .toDouble(), + 1.0); + + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ThreeWayColorNode::kShadowsAmountInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ThreeWayColorNode::kMidtonesAmountInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ThreeWayColorNode::kHighlightsAmountInput, + QStringLiteral("min")) + .toDouble(), + 0.0); +} + +TEST(ThreeWayColorNode, RetranslateSetsInputNames) +{ + olive::ThreeWayColorNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::ThreeWayColorNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::ThreeWayColorNode::kShadowsColorInput), + QStringLiteral("Shadows")); + EXPECT_EQ(node.GetInputName(olive::ThreeWayColorNode::kMidtonesColorInput), + QStringLiteral("Midtones")); + EXPECT_EQ( + node.GetInputName(olive::ThreeWayColorNode::kHighlightsColorInput), + QStringLiteral("Highlights")); + EXPECT_EQ( + node.GetInputName(olive::ThreeWayColorNode::kShadowsAmountInput), + QStringLiteral("Shadows Amount")); + EXPECT_EQ( + node.GetInputName(olive::ThreeWayColorNode::kMidtonesAmountInput), + QStringLiteral("Midtones Amount")); + EXPECT_EQ( + node.GetInputName(olive::ThreeWayColorNode::kHighlightsAmountInput), + QStringLiteral("Highlights Amount")); +} + +// ----------------------------------------------------------------------------- +// OCIOGradingTransformLinearNode (beyond the clamp-invariant coverage in +// color_lut_test.cpp) +// ----------------------------------------------------------------------------- + +TEST(GradingTransformLinear, InputDefaults) +{ + olive::OCIOGradingTransformLinearNode node; + + const QVector4D contrast = + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kContrastInput) + .value(); + EXPECT_FLOAT_EQ(contrast.x(), 1.0f); + EXPECT_FLOAT_EQ(contrast.y(), 1.0f); + EXPECT_FLOAT_EQ(contrast.z(), 1.0f); + EXPECT_FLOAT_EQ(contrast.w(), 1.0f); + + const QVector4D offset = + node.GetStandardValue(olive::OCIOGradingTransformLinearNode::kOffsetInput) + .value(); + EXPECT_FLOAT_EQ(offset.x(), 0.0f); + EXPECT_FLOAT_EQ(offset.y(), 0.0f); + EXPECT_FLOAT_EQ(offset.z(), 0.0f); + EXPECT_FLOAT_EQ(offset.w(), 0.0f); + + const QVector4D exposure = + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kExposureInput) + .value(); + EXPECT_FLOAT_EQ(exposure.x(), 0.0f); + EXPECT_FLOAT_EQ(exposure.y(), 0.0f); + EXPECT_FLOAT_EQ(exposure.z(), 0.0f); + EXPECT_FLOAT_EQ(exposure.w(), 0.0f); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kSaturationInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::OCIOGradingTransformLinearNode::kPivotInput) + .toDouble(), + 0.18); + + EXPECT_FALSE( + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampBlackEnableInput) + .toBool()); + EXPECT_FALSE( + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput) + .toBool()); + EXPECT_DOUBLE_EQ( + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampBlackInput) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput) + .toDouble(), + 1.0); + + // Clamp value inputs start out disabled, matching the enable toggles. + EXPECT_FALSE( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampBlackInput, + QStringLiteral("enabled")) + .toBool()); + EXPECT_FALSE( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("enabled")) + .toBool()); +} + +TEST(GradingTransformLinear, Identity) +{ + olive::OCIOGradingTransformLinearNode node; + + EXPECT_EQ(node.id(), + QStringLiteral( + "org.olivevideoeditor.Olive.ociogradingtransformlinear")); + EXPECT_FALSE(node.Name().isEmpty()); + EXPECT_FALSE(node.Description().isEmpty()); + + ASSERT_EQ(node.Category().size(), 1); + EXPECT_EQ(int(node.Category().first()), int(olive::Node::kCategoryColor)); +} + +TEST(GradingTransformLinear, ClampEnableTogglesEnabledProperty) +{ + olive::OCIOGradingTransformLinearNode node; + + node.SetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput, true); + EXPECT_TRUE( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("enabled")) + .toBool()); + + node.SetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampBlackEnableInput, true); + EXPECT_TRUE( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampBlackInput, + QStringLiteral("enabled")) + .toBool()); + + node.SetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput, false); + EXPECT_FALSE( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("enabled")) + .toBool()); +} + +TEST(GradingTransformLinear, WhiteClampMinimumFollowsStaticBlackClamp) +{ + olive::OCIOGradingTransformLinearNode node; + + // Constructor seeds the white clamp minimum just above the black clamp. + EXPECT_DOUBLE_EQ( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("min")) + .toDouble(), + 0.000001); + + node.SetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampBlackInput, 0.5); + EXPECT_DOUBLE_EQ( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("min")) + .toDouble(), + 0.5 + 0.000001); +} + +TEST(GradingTransformLinear, WhiteClampMinimumNotUpdatedWhenBlackKeyframed) +{ + olive::OCIOGradingTransformLinearNode node; + + // With the black clamp keyframing, the static UI minimum can no longer + // follow it; the invariant is enforced per frame in Value() instead. + node.SetInputIsKeyframing( + olive::OCIOGradingTransformLinearNode::kClampBlackInput, true); + node.SetStandardValue( + olive::OCIOGradingTransformLinearNode::kClampBlackInput, 0.5); + + EXPECT_DOUBLE_EQ( + node.GetInputProperty( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput, + QStringLiteral("min")) + .toDouble(), + 0.000001); +} + +TEST(GradingTransformLinear, ValueWithoutProcessorPushesNothing) +{ + // Without a project no color manager is attached, so no processor is ever + // generated and Value() must push nothing even with a valid texture. + olive::OCIOGradingTransformLinearNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OCIOBaseNode::kTextureInput, tex); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(GradingTransformLinear, ValueInProjectPushesColorTransformJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + auto *node = new olive::OCIOGradingTransformLinearNode(); + node->setParent(&project); + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OCIOBaseNode::kTextureInput, tex); + row.insert(olive::OCIOGradingTransformLinearNode::kOffsetInput, + Vec4Value(QVector4D(0.0f, 0.0f, 0.0f, 0.0f))); + row.insert(olive::OCIOGradingTransformLinearNode::kExposureInput, + Vec4Value(QVector4D(0.0f, 0.0f, 0.0f, 0.0f))); + row.insert(olive::OCIOGradingTransformLinearNode::kContrastInput, + Vec4Value(QVector4D(1.0f, 1.0f, 1.0f, 1.0f))); + row.insert(olive::OCIOGradingTransformLinearNode::kClampBlackEnableInput, + BoolValue(false)); + row.insert(olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput, + BoolValue(false)); + + olive::NodeValueTable table; + node->Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = static_cast(out->job()); + ASSERT_NE(job, nullptr); + EXPECT_NE(job->GetColorProcessor(), nullptr); + + const olive::NodeValueRow &values = job->GetValues(); + + // Defaults convert to neutral vec3s for OCIO. + const QVector3D offset = + values.value(olive::OCIOGradingTransformLinearNode::kOffsetInput) + .toVec3(); + EXPECT_FLOAT_EQ(offset.x(), 0.0f); + EXPECT_FLOAT_EQ(offset.y(), 0.0f); + EXPECT_FLOAT_EQ(offset.z(), 0.0f); + + const QVector3D exposure = + values.value(olive::OCIOGradingTransformLinearNode::kExposureInput) + .toVec3(); + EXPECT_FLOAT_EQ(exposure.x(), 1.0f); + EXPECT_FLOAT_EQ(exposure.y(), 1.0f); + EXPECT_FLOAT_EQ(exposure.z(), 1.0f); + + const QVector3D contrast = + values.value(olive::OCIOGradingTransformLinearNode::kContrastInput) + .toVec3(); + EXPECT_FLOAT_EQ(contrast.x(), 1.0f); + EXPECT_FLOAT_EQ(contrast.y(), 1.0f); + EXPECT_FLOAT_EQ(contrast.z(), 1.0f); + + // Disabled clamps are replaced with OCIO's "no clamp" sentinels. + ASSERT_TRUE( + values.contains(olive::OCIOGradingTransformLinearNode::kClampBlackInput)); + EXPECT_LT(values + .value(olive::OCIOGradingTransformLinearNode::kClampBlackInput) + .toDouble(), + -1e300); + ASSERT_TRUE( + values.contains(olive::OCIOGradingTransformLinearNode::kClampWhiteInput)); + EXPECT_GT(values + .value(olive::OCIOGradingTransformLinearNode::kClampWhiteInput) + .toDouble(), + 1e300); +} + +TEST(GradingTransformLinear, ValueAppliesMasterChannelMath) +{ + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + auto *node = new olive::OCIOGradingTransformLinearNode(); + node->setParent(&project); + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OCIOBaseNode::kTextureInput, tex); + // Layout is {master, red, green, blue}. + row.insert(olive::OCIOGradingTransformLinearNode::kOffsetInput, + Vec4Value(QVector4D(0.1f, 0.2f, 0.3f, 0.4f))); + row.insert(olive::OCIOGradingTransformLinearNode::kExposureInput, + Vec4Value(QVector4D(1.0f, 0.0f, 0.0f, 0.0f))); + row.insert(olive::OCIOGradingTransformLinearNode::kContrastInput, + Vec4Value(QVector4D(2.0f, 0.5f, 1.0f, 1.0f))); + row.insert(olive::OCIOGradingTransformLinearNode::kClampBlackEnableInput, + BoolValue(false)); + row.insert(olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput, + BoolValue(false)); + + olive::NodeValueTable table; + node->Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = static_cast(out->job()); + ASSERT_NE(job, nullptr); + + const olive::NodeValueRow &values = job->GetValues(); + + // Offset: master is added to each channel. + const QVector3D offset = + values.value(olive::OCIOGradingTransformLinearNode::kOffsetInput) + .toVec3(); + EXPECT_NEAR(offset.x(), 0.3f, 0.0001f); + EXPECT_NEAR(offset.y(), 0.4f, 0.0001f); + EXPECT_NEAR(offset.z(), 0.5f, 0.0001f); + + // Exposure: channels become 2^(master + channel) gain values. + const QVector3D exposure = + values.value(olive::OCIOGradingTransformLinearNode::kExposureInput) + .toVec3(); + EXPECT_NEAR(exposure.x(), 2.0f, 0.0001f); + EXPECT_NEAR(exposure.y(), 2.0f, 0.0001f); + EXPECT_NEAR(exposure.z(), 2.0f, 0.0001f); + + // Contrast: master multiplies each channel. + const QVector3D contrast = + values.value(olive::OCIOGradingTransformLinearNode::kContrastInput) + .toVec3(); + EXPECT_NEAR(contrast.x(), 1.0f, 0.0001f); + EXPECT_NEAR(contrast.y(), 2.0f, 0.0001f); + EXPECT_NEAR(contrast.z(), 2.0f, 0.0001f); +} + +TEST(GradingTransformLinear, RetranslateSetsInputNames) +{ + olive::OCIOGradingTransformLinearNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::OCIOBaseNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ( + node.GetInputName(olive::OCIOGradingTransformLinearNode::kContrastInput), + QStringLiteral("Contrast")); + EXPECT_EQ( + node.GetInputName(olive::OCIOGradingTransformLinearNode::kOffsetInput), + QStringLiteral("Offset")); + EXPECT_EQ( + node.GetInputName(olive::OCIOGradingTransformLinearNode::kExposureInput), + QStringLiteral("Exposure")); + EXPECT_EQ( + node.GetInputName( + olive::OCIOGradingTransformLinearNode::kSaturationInput), + QStringLiteral("Saturation")); + EXPECT_EQ( + node.GetInputName(olive::OCIOGradingTransformLinearNode::kPivotInput), + QStringLiteral("Pivot")); + EXPECT_EQ( + node.GetInputName( + olive::OCIOGradingTransformLinearNode::kClampBlackEnableInput), + QStringLiteral("Enable Black Clamp")); + EXPECT_EQ( + node.GetInputName( + olive::OCIOGradingTransformLinearNode::kClampBlackInput), + QStringLiteral("Black Clamp")); + EXPECT_EQ( + node.GetInputName( + olive::OCIOGradingTransformLinearNode::kClampWhiteEnableInput), + QStringLiteral("Enable White Clamp")); + EXPECT_EQ( + node.GetInputName( + olive::OCIOGradingTransformLinearNode::kClampWhiteInput), + QStringLiteral("White Clamp")); +} diff --git a/tests/gtest/node_time_test.cpp b/tests/gtest/node_time_test.cpp new file mode 100644 index 000000000..361ea985c --- /dev/null +++ b/tests/gtest/node_time_test.cpp @@ -0,0 +1,460 @@ +#include + +#include + +#include +#include + +#include "node/block/gap/gap.h" +#include "node/color/colormanager/colormanager.h" +#include "node/globals.h" +#include "node/input/time/timeinput.h" +#include "node/keyframe.h" +#include "node/project.h" +#include "node/time/timeformat/timeformat.h" +#include "node/time/timeoffset/timeoffsetnode.h" +#include "node/time/timeremap/timeremap.h" +#include "node/traverser.h" +#include "olive/core/util/rational.h" +#include "olive/core/util/timerange.h" + +namespace +{ + +class NodeTimeTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + olive::NodeKeyframe *AddKey(olive::Node *node, const QString &input, + const olive::core::rational &time, + const QVariant &value) + { + auto *key = new olive::NodeKeyframe( + time, value, olive::NodeKeyframe::kLinear, 0, -1, input); + key->setParent(node); + return key; + } + + // Generates the node's output table at a single time with a fresh + // traverser (the traverser caches tables per node+range, so reusing one + // would return stale values after the node's parameters change) + olive::NodeValueTable GenerateTable(const olive::Node *node, + const olive::core::rational &time) + { + olive::NodeTraverser traverser; + return traverser.GenerateTable( + node, olive::TimeRange(time, time + olive::core::rational(1, 30))); + } + + std::unique_ptr project_; +}; + +} // namespace + +TEST(GapBlock, Metadata) +{ + olive::GapBlock gap; + + EXPECT_EQ(gap.Name(), QStringLiteral("Gap")); + EXPECT_EQ(gap.id(), QStringLiteral("org.olivevideoeditor.Olive.gap")); + EXPECT_FALSE(gap.Description().isEmpty()); + EXPECT_TRUE(gap.Category().contains(olive::Node::kCategoryTimeline)); +} + +TEST(GapBlock, DefaultLengthIsZero) +{ + olive::GapBlock gap; + + EXPECT_EQ(gap.length(), olive::core::rational(0)); +} + +TEST_F(NodeTimeTest, GapBlockStoresRationalLength) +{ + auto *gap = AddNode(); + + gap->set_length_and_media_out(olive::core::rational(7, 2)); + EXPECT_EQ(gap->length(), olive::core::rational(7, 2)); + + gap->set_length_and_media_out(olive::core::rational(0)); + EXPECT_EQ(gap->length(), olive::core::rational(0)); +} + +TEST(TimeOffsetNode, Metadata) +{ + olive::TimeOffsetNode offset; + + EXPECT_EQ(offset.Name(), QStringLiteral("Time Offset")); + EXPECT_EQ(offset.id(), + QStringLiteral("org.olivevideoeditor.Olive.timeoffset")); + EXPECT_FALSE(offset.Description().isEmpty()); + EXPECT_TRUE(offset.Category().contains(olive::Node::kCategoryTime)); +} + +TEST(TimeOffsetNode, InputFlags) +{ + olive::TimeOffsetNode offset; + + // The time parameter is keyframable but cannot take an edge + EXPECT_FALSE(offset.IsInputConnectable(olive::TimeOffsetNode::kTimeInput)); + EXPECT_TRUE(offset.IsInputKeyframable(olive::TimeOffsetNode::kTimeInput)); + + // The data input takes an edge but cannot be keyframed + EXPECT_TRUE(offset.IsInputConnectable(olive::TimeOffsetNode::kInputInput)); + EXPECT_FALSE(offset.IsInputKeyframable(olive::TimeOffsetNode::kInputInput)); + + // The time offset defaults to zero + EXPECT_EQ(offset.GetStandardValue(olive::TimeOffsetNode::kTimeInput) + .value(), + olive::core::rational(0)); +} + +TEST(TimeOffsetNode, RetranslateSetsInputNames) +{ + olive::TimeOffsetNode offset; + + offset.Retranslate(); + + EXPECT_EQ(offset.GetInputName(olive::TimeOffsetNode::kTimeInput), + QStringLiteral("Time")); + EXPECT_EQ(offset.GetInputName(olive::TimeOffsetNode::kInputInput), + QStringLiteral("Input")); +} + +TEST_F(NodeTimeTest, TimeOffsetAppliesStaticOffset) +{ + auto *offset = AddNode(); + offset->SetStandardValue(olive::TimeOffsetNode::kTimeInput, + QVariant::fromValue(olive::core::rational(3))); + + // The connected input is evaluated offset seconds later + EXPECT_EQ(offset->InputTimeAdjustment( + olive::TimeOffsetNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(5), + olive::core::rational(7))); + + // Any other input passes time through unchanged + const olive::TimeRange range(olive::core::rational(2), + olive::core::rational(4)); + EXPECT_EQ(offset->InputTimeAdjustment(olive::TimeOffsetNode::kTimeInput, + -1, range, true), + range); +} + +TEST_F(NodeTimeTest, TimeOffsetAppliesNegativeOffset) +{ + auto *offset = AddNode(); + offset->SetStandardValue(olive::TimeOffsetNode::kTimeInput, + QVariant::fromValue(olive::core::rational(-3))); + + // Negative offsets move the requested time before the sequence time, + // even across zero + EXPECT_EQ(offset->InputTimeAdjustment( + olive::TimeOffsetNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(-1), + olive::core::rational(1))); +} + +TEST_F(NodeTimeTest, TimeOffsetZeroOffsetIsIdentity) +{ + auto *offset = AddNode(); + + const olive::TimeRange range(olive::core::rational(2), + olive::core::rational(4)); + EXPECT_EQ(offset->InputTimeAdjustment(olive::TimeOffsetNode::kInputInput, + -1, range, true), + range); +} + +TEST_F(NodeTimeTest, TimeOffsetOutputAdjustmentPassesThrough) +{ + auto *offset = AddNode(); + offset->SetStandardValue(olive::TimeOffsetNode::kTimeInput, + QVariant::fromValue(olive::core::rational(3))); + + // The inverse mapping is not implemented, so output time is never + // adjusted + const olive::TimeRange range(olive::core::rational(2), + olive::core::rational(4)); + EXPECT_EQ(offset->OutputTimeAdjustment(olive::TimeOffsetNode::kInputInput, + -1, range), + range); + EXPECT_EQ(offset->OutputTimeAdjustment(olive::TimeOffsetNode::kTimeInput, + -1, range), + range); +} + +TEST_F(NodeTimeTest, TimeOffsetAppliesKeyframedOffset) +{ + auto *offset = AddNode(); + offset->SetInputIsKeyframing(olive::TimeOffsetNode::kTimeInput, true); + + AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::core::rational(0), + QVariant::fromValue(olive::core::rational(0))); + AddKey(offset, olive::TimeOffsetNode::kTimeInput, + olive::core::rational(10), + QVariant::fromValue(olive::core::rational(10))); + + // The offset is sampled per endpoint: 2 + 2 = 4 and 4 + 4 = 8 + EXPECT_EQ(offset->InputTimeAdjustment( + olive::TimeOffsetNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(4), + olive::core::rational(8))); +} + +TEST_F(NodeTimeTest, TimeOffsetValuePassesConnectedInputThrough) +{ + auto *offset = AddNode(); + offset->SetStandardValue(olive::TimeOffsetNode::kTimeInput, + QVariant::fromValue(olive::core::rational(5))); + + auto *time = AddNode(); + olive::Node::ConnectEdge( + time, olive::NodeInput(offset, olive::TimeOffsetNode::kInputInput)); + + // The connected node is evaluated at the offset time: 3 + 5 = 8 + const olive::NodeValueTable table = GenerateTable(offset, olive::core::rational(3)); + EXPECT_DOUBLE_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 8.0); +} + +TEST_F(NodeTimeTest, TimeOffsetValueWithoutConnectionProducesNoFloat) +{ + auto *offset = AddNode(); + + // With nothing connected the node pushes the (typeless) standard value + const olive::NodeValueTable table = GenerateTable(offset, olive::core::rational(3)); + EXPECT_EQ(table.Get(olive::NodeValue::kFloat).type(), + olive::NodeValue::kNone); +} + +TEST(TimeRemapNode, Metadata) +{ + olive::TimeRemapNode remap; + + EXPECT_EQ(remap.Name(), QStringLiteral("Time Remap")); + EXPECT_EQ(remap.id(), + QStringLiteral("org.olivevideoeditor.Olive.timeremap")); + EXPECT_FALSE(remap.Description().isEmpty()); + EXPECT_TRUE(remap.Category().contains(olive::Node::kCategoryTime)); +} + +TEST(TimeRemapNode, InputFlags) +{ + olive::TimeRemapNode remap; + + EXPECT_FALSE(remap.IsInputConnectable(olive::TimeRemapNode::kTimeInput)); + EXPECT_TRUE(remap.IsInputKeyframable(olive::TimeRemapNode::kTimeInput)); + + EXPECT_TRUE(remap.IsInputConnectable(olive::TimeRemapNode::kInputInput)); + EXPECT_FALSE(remap.IsInputKeyframable(olive::TimeRemapNode::kInputInput)); + + EXPECT_EQ(remap.GetStandardValue(olive::TimeRemapNode::kTimeInput) + .value(), + olive::core::rational(0)); +} + +TEST(TimeRemapNode, RetranslateSetsInputNames) +{ + olive::TimeRemapNode remap; + + remap.Retranslate(); + + EXPECT_EQ(remap.GetInputName(olive::TimeRemapNode::kTimeInput), + QStringLiteral("Time")); + EXPECT_EQ(remap.GetInputName(olive::TimeRemapNode::kInputInput), + QStringLiteral("Input")); +} + +TEST_F(NodeTimeTest, TimeRemapStaticTimeCollapsesRange) +{ + auto *remap = AddNode(); + remap->SetStandardValue(olive::TimeRemapNode::kTimeInput, + QVariant::fromValue(olive::core::rational(7))); + + // A constant remap maps every sequence time onto the same media time + const olive::TimeRange adjusted = remap->InputTimeAdjustment( + olive::TimeRemapNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), olive::core::rational(4)), + true); + EXPECT_EQ(adjusted.in(), olive::core::rational(7)); + EXPECT_EQ(adjusted.out(), olive::core::rational(7)); +} + +TEST_F(NodeTimeTest, TimeRemapNonInputPassesThrough) +{ + auto *remap = AddNode(); + remap->SetStandardValue(olive::TimeRemapNode::kTimeInput, + QVariant::fromValue(olive::core::rational(7))); + + const olive::TimeRange range(olive::core::rational(2), + olive::core::rational(4)); + EXPECT_EQ(remap->InputTimeAdjustment(olive::TimeRemapNode::kTimeInput, -1, + range, true), + range); + EXPECT_EQ(remap->OutputTimeAdjustment(olive::TimeRemapNode::kInputInput, + -1, range), + range); + EXPECT_EQ(remap->OutputTimeAdjustment(olive::TimeRemapNode::kTimeInput, -1, + range), + range); +} + +TEST_F(NodeTimeTest, TimeRemapAppliesKeyframedLinearRamp) +{ + auto *remap = AddNode(); + remap->SetInputIsKeyframing(olive::TimeRemapNode::kTimeInput, true); + + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(0), + QVariant::fromValue(olive::core::rational(0))); + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(10), + QVariant::fromValue(olive::core::rational(100))); + + // The linear ramp multiplies time by ten: 2 -> 20 and 4 -> 40 + EXPECT_EQ(remap->InputTimeAdjustment( + olive::TimeRemapNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(20), + olive::core::rational(40))); +} + +TEST_F(NodeTimeTest, TimeRemapReversedRampNormalizesRange) +{ + auto *remap = AddNode(); + remap->SetInputIsKeyframing(olive::TimeRemapNode::kTimeInput, true); + + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(0), + QVariant::fromValue(olive::core::rational(10))); + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(10), + QVariant::fromValue(olive::core::rational(0))); + + // Decreasing time values invert the range: 2 -> 8 and 4 -> 6, which + // TimeRange normalizes back to [6, 8] + EXPECT_EQ(remap->InputTimeAdjustment( + olive::TimeRemapNode::kInputInput, -1, + olive::TimeRange(olive::core::rational(2), + olive::core::rational(4)), + true), + olive::TimeRange(olive::core::rational(6), + olive::core::rational(8))); +} + +TEST_F(NodeTimeTest, TimeRemapValuePassesConnectedInputThrough) +{ + auto *remap = AddNode(); + remap->SetInputIsKeyframing(olive::TimeRemapNode::kTimeInput, true); + + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(0), + QVariant::fromValue(olive::core::rational(0))); + AddKey(remap, olive::TimeRemapNode::kTimeInput, olive::core::rational(10), + QVariant::fromValue(olive::core::rational(100))); + + auto *time = AddNode(); + olive::Node::ConnectEdge( + time, olive::NodeInput(remap, olive::TimeRemapNode::kInputInput)); + + // The connected node is evaluated at the remapped time: 3 -> 30 + const olive::NodeValueTable table = GenerateTable(remap, olive::core::rational(3)); + EXPECT_DOUBLE_EQ(table.Get(olive::NodeValue::kFloat).toDouble(), 30.0); +} + +TEST(TimeFormatNode, Metadata) +{ + olive::TimeFormatNode format; + + EXPECT_EQ(format.Name(), QStringLiteral("Time Format")); + EXPECT_EQ(format.id(), + QStringLiteral("org.olivevideoeditor.Olive.timeformat")); + EXPECT_FALSE(format.Description().isEmpty()); + EXPECT_TRUE(format.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(TimeFormatNode, RetranslateSetsInputNames) +{ + olive::TimeFormatNode format; + + format.Retranslate(); + + EXPECT_EQ(format.GetInputName(olive::TimeFormatNode::kTimeInput), + QStringLiteral("Time")); + EXPECT_EQ(format.GetInputName(olive::TimeFormatNode::kFormatInput), + QStringLiteral("Format")); + EXPECT_EQ(format.GetInputName(olive::TimeFormatNode::kLocalTimeInput), + QStringLiteral("Interpret time as local time")); +} + +TEST_F(NodeTimeTest, TimeFormatDefaultsToUtcEpoch) +{ + auto *format = AddNode(); + + // Local time interpretation is off by default, keeping output + // independent of the machine timezone + EXPECT_FALSE(format->GetStandardValue(olive::TimeFormatNode::kLocalTimeInput) + .toBool()); + EXPECT_EQ(format->GetStandardValue(olive::TimeFormatNode::kFormatInput) + .toString(), + QStringLiteral("hh:mm:ss")); + + // A null time value behaves as 0, the Unix epoch + const olive::NodeValueTable table = GenerateTable(format, olive::core::rational(0)); + EXPECT_EQ(table.Get(olive::NodeValue::kText).toString(), + QStringLiteral("00:00:00")); +} + +TEST_F(NodeTimeTest, TimeFormatFormatsUtcTime) +{ + auto *format = AddNode(); + format->SetStandardValue(olive::TimeFormatNode::kTimeInput, 3661.0); + + const olive::NodeValueTable table = GenerateTable(format, olive::core::rational(0)); + EXPECT_EQ(table.Get(olive::NodeValue::kText).toString(), + QStringLiteral("01:01:01")); +} + +TEST_F(NodeTimeTest, TimeFormatHonorsCustomFormat) +{ + auto *format = AddNode(); + format->SetStandardValue(olive::TimeFormatNode::kTimeInput, 3661.0); + format->SetStandardValue(olive::TimeFormatNode::kFormatInput, + QStringLiteral("yyyy-MM-dd mm:ss")); + + // The custom format replaces the default; 3661s = 01:01:01 UTC + const olive::NodeValueTable table = GenerateTable(format, olive::core::rational(0)); + EXPECT_EQ(table.Get(olive::NodeValue::kText).toString(), + QStringLiteral("1970-01-01 01:01")); +} + +TEST_F(NodeTimeTest, TimeFormatFormatsNegativeTimeBeforeEpoch) +{ + auto *format = AddNode(); + format->SetStandardValue(olive::TimeFormatNode::kTimeInput, -1.0); + format->SetStandardValue(olive::TimeFormatNode::kFormatInput, + QStringLiteral("yyyy-MM-dd hh:mm:ss")); + + // One second before the epoch + const olive::NodeValueTable table = GenerateTable(format, olive::core::rational(0)); + EXPECT_EQ(table.Get(olive::NodeValue::kText).toString(), + QStringLiteral("1969-12-31 23:59:59")); +} diff --git a/tests/gtest/plugin_node_test.cpp b/tests/gtest/plugin_node_test.cpp new file mode 100644 index 000000000..9250e4af8 --- /dev/null +++ b/tests/gtest/plugin_node_test.cpp @@ -0,0 +1,342 @@ +/* + * Oak Video Editor - Plugin Node / OFX Host Tests + * Copyright (C) 2025 Olive CE Team + * + * CPU-only tests for app/pluginSupport/OliveHost.cpp and the constructible + * surface of app/node/plugins/Plugin.h. + * + * PluginNode and OlivePluginInstance cannot be instantiated in a unit test: + * PluginNode's constructor dereferences an OFX::Host::ImageEffect::Instance, + * and the HostSupport Instance constructor requires a plugin binary that + * dlopen()s successfully (ofxhImageEffect.cpp calls + * plugin->getPluginHandle()->getOfxPlugin(), which needs the OfxGetPlugin + * symbol of a real .ofx bundle). The host-side surface (pluginSupported, + * descriptor factory, message routing, loadPlugins) is exercised here with a + * fake ImageEffectPlugin backed by a deliberately invalid PluginBinary. + */ + +#include + +#include +#include +#include + +#include +#include +#include + +#include "ofxCore.h" +#include "ofxImageEffect.h" +#include "ofxMessage.h" +#include "ofxhImageEffect.h" +#include "ofxhPluginCache.h" + +#include "common/Current.h" +#include "node/plugins/Plugin.h" +#include "pluginSupport/OliveHost.h" + +namespace +{ + +// Paths that never exist on disk: the PluginBinary stats the file, marks +// itself invalid, and every code path used below tolerates that without ever +// calling dlopen(). +constexpr char kFakeBundlePath[] = "/nonexistent/Fake.ofx.bundle"; +constexpr char kFakeBinaryPath[] = + "/nonexistent/Fake.ofx.bundle/Contents/Linux-x86-64/Fake.ofx"; +constexpr char kFakePluginId[] = "com.oak.test.FakePlugin"; + +// Builds an OliveHost, an ImageEffect::PluginCache bound to it (which sets +// the global gImageEffectHost), an invalid PluginBinary, and a fake +// ImageEffectPlugin whose construction runs through OliveHost::makeDescriptor. +// The saver member restores gImageEffectHost on destruction so other tests +// keep observing the global state they set up themselves. +struct FakePluginHarness { + struct HostGlobalSaver { + HostGlobalSaver() + : previous(OFX::Host::ImageEffect::gImageEffectHost) + { + } + ~HostGlobalSaver() + { + OFX::Host::ImageEffect::gImageEffectHost = previous; + } + OFX::Host::ImageEffect::Host *previous; + }; + + HostGlobalSaver saver; + olive::plugin::OliveHost host; + OFX::Host::ImageEffect::PluginCache cache{ host }; + OFX::Host::PluginBinary binary{ kFakeBinaryPath, kFakeBundlePath, 0, 0 }; + OFX::Host::ImageEffect::ImageEffectPlugin plugin{ cache, &binary, 0, + kOfxImageEffectPluginApi, + 1, + kFakePluginId, + kFakePluginId, + 1, + 0 }; +}; + +OfxStatus CallVMessage(olive::plugin::OliveHost &host, const char *type, + const char *id, const char *format, ...) +{ + va_list args; + va_start(args, format); + const OfxStatus status = host.vmessage(type, id, format, args); + va_end(args); + return status; +} + +OfxStatus CallSetPersistentMessage(olive::plugin::OliveHost &host, + const char *type, const char *id, + const char *format, ...) +{ + va_list args; + va_start(args, format); + const OfxStatus status = host.setPersistentMessage(type, id, format, args); + va_end(args); + return status; +} + +} // namespace + +// ============================================================================ +// OliveHost::pluginSupported +// ============================================================================ + +TEST(OliveHost, PluginSupportedRejectsNullPlugin) +{ + FakePluginHarness harness; + + std::string reason; + EXPECT_FALSE(harness.host.pluginSupported(nullptr, reason)); + EXPECT_EQ(reason, "null plugin"); +} + +TEST(OliveHost, PluginSupportedRejectsPluginWithoutContexts) +{ + FakePluginHarness harness; + + // The fake plugin was never described, so it supports no contexts. + std::string reason; + EXPECT_FALSE(harness.host.pluginSupported(&harness.plugin, reason)); + EXPECT_EQ(reason, "no supported contexts (describe failed)"); +} + +TEST(OliveHost, PluginSupportedAcceptsPluginWithKnownContext) +{ + FakePluginHarness harness; + harness.plugin.addContext(kOfxImageEffectContextFilter); + + std::string reason; + EXPECT_TRUE(harness.host.pluginSupported(&harness.plugin, reason)); + EXPECT_TRUE(reason.empty()); +} + +TEST(OliveHost, FakePluginExposesConstructionMetadata) +{ + FakePluginHarness harness; + + EXPECT_EQ(harness.plugin.getIdentifier(), kFakePluginId); + EXPECT_EQ(harness.plugin.getVersionMajor(), 1); + EXPECT_EQ(harness.plugin.getVersionMinor(), 0); + // Construction went through OliveHost::makeDescriptor(plugin), which + // stamps the descriptor with the binary's bundle path. + EXPECT_EQ(harness.plugin.getDescriptor().getProps().getStringProperty( + kOfxPluginPropFilePath), + kFakeBundlePath); +} + +// ============================================================================ +// OliveHost::makeDescriptor (descriptor store) +// ============================================================================ + +TEST(OliveHost, MakeDescriptorFromBundlePathRecordsFilePath) +{ + FakePluginHarness harness; + + auto first = harness.host.makeDescriptor( + std::string("/nonexistent/One.ofx.bundle"), nullptr); + auto second = harness.host.makeDescriptor( + std::string("/nonexistent/Two.ofx.bundle"), nullptr); + + ASSERT_NE(first, nullptr); + ASSERT_NE(second, nullptr); + EXPECT_NE(first, second); + EXPECT_EQ(first->getProps().getStringProperty(kOfxPluginPropFilePath), + "/nonexistent/One.ofx.bundle"); + EXPECT_EQ(second->getProps().getStringProperty(kOfxPluginPropFilePath), + "/nonexistent/Two.ofx.bundle"); + EXPECT_EQ(first->getProps().getStringProperty(kOfxPropType), + kOfxTypeImageEffect); +} + +TEST(OliveHost, MakeDescriptorFromRootContextCopiesProperties) +{ + FakePluginHarness harness; + + auto root = harness.host.makeDescriptor( + std::string("/nonexistent/Root.ofx.bundle"), nullptr); + ASSERT_NE(root, nullptr); + root->getProps().setStringProperty(kOfxPropLabel, "Root Label"); + + auto desc = harness.host.makeDescriptor(*root, &harness.plugin); + ASSERT_NE(desc, nullptr); + + // Properties are inherited from the root context ... + EXPECT_EQ(desc->getProps().getStringProperty(kOfxPropLabel), + "Root Label"); + // ... while the file path is stamped from the plugin's own binary. + EXPECT_EQ(desc->getProps().getStringProperty(kOfxPluginPropFilePath), + kFakeBundlePath); +} + +// ============================================================================ +// OliveHost::destroyInstance +// ============================================================================ + +TEST(OliveHost, DestroyInstanceIgnoresNull) +{ + olive::plugin::OliveHost host; + EXPECT_NO_THROW(host.destroyInstance(nullptr)); +} + +// ============================================================================ +// OliveHost message routing (error paths only) +// +// The successful vmessage/setPersistentMessage paths pop modal QMessageBox +// dialogs whenever a QApplication exists, which a headless test cannot +// dismiss, so only the early-return failure paths are exercised here. +// ============================================================================ + +TEST(OliveHost, VMessageRejectsNullArguments) +{ + olive::plugin::OliveHost host; + EXPECT_EQ(CallVMessage(host, nullptr, "id", "%s", "x"), kOfxStatFailed); + EXPECT_EQ(CallVMessage(host, kOfxMessageError, "id", nullptr), + kOfxStatFailed); +} + +TEST(OliveHost, SetPersistentMessageRejectsNullArguments) +{ + olive::plugin::OliveHost host; + EXPECT_EQ(CallSetPersistentMessage(host, nullptr, "id", "%s", "x"), + kOfxStatFailed); + EXPECT_EQ(CallSetPersistentMessage(host, kOfxMessageError, "id", nullptr), + kOfxStatFailed); +} + +TEST(OliveHost, SetPersistentMessageRejectsUnknownType) +{ + olive::plugin::OliveHost host; + // A type that is neither error, warning, nor message fails before any + // dialog would be shown. + EXPECT_EQ(CallSetPersistentMessage(host, "OfxMessageBogus", "id", "%s", + "hello"), + kOfxStatFailed); +} + +TEST(OliveHost, ClearPersistentMessageSucceeds) +{ + olive::plugin::OliveHost host; + EXPECT_EQ(host.clearPersistentMessage(), kOfxStatOK); +} + +// ============================================================================ +// OliveHost suite/property surface (inherited from OFX::Host::ImageEffect::Host) +// ============================================================================ + +TEST(OliveHost, FetchSuiteProvidesExpectedSuites) +{ + olive::plugin::OliveHost host; + + EXPECT_NE(host.fetchSuite(kOfxImageEffectSuite, 1), nullptr); + EXPECT_EQ(host.fetchSuite(kOfxImageEffectSuite, 2), nullptr); + EXPECT_NE(host.fetchSuite(kOfxPropertySuite, 1), nullptr); + EXPECT_NE(host.fetchSuite(kOfxMemorySuite, 1), nullptr); + EXPECT_NE(host.fetchSuite(kOfxMessageSuite, 1), nullptr); + EXPECT_NE(host.fetchSuite(kOfxMessageSuite, 2), nullptr); + EXPECT_NE(host.fetchSuite(kOfxParameterSuite, 1), nullptr); + EXPECT_EQ(host.fetchSuite("com.oak.BogusSuite", 1), nullptr); +} + +TEST(OliveHost, HostPropertiesIdentifyAsOfxHost) +{ + // HostSupport seeds the host property set with the literal "Host" + // (ofxhHost.cpp hostStuffs), not kOfxTypeImageEffectHost. + olive::plugin::OliveHost host; + EXPECT_EQ(host.getProperties().getStringProperty(kOfxPropType), "Host"); +} + +#ifdef OFX_SUPPORTS_OPENGLRENDER +TEST(OliveHost, FlushOpenGLResourcesReportsFailure) +{ + // No GL context exists in a headless test; the host must report failure + // rather than crash. + olive::plugin::OliveHost host; + EXPECT_EQ(host.flushOpenGLResources(), kOfxStatFailed); +} +#endif + +// ============================================================================ +// olive::plugin::loadPlugins +// ============================================================================ + +TEST(OliveHost, LoadPluginsInitializesAndReusesCurrentHost) +{ + olive::plugin::loadPlugins(QString()); + + std::shared_ptr host = + Current::getInstance().pluginHost(); + std::shared_ptr cache = + Current::getInstance().pluginCache(); + ASSERT_NE(host, nullptr); + ASSERT_NE(cache, nullptr); + + // A second call must reuse the already-created host and cache. + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + olive::plugin::loadPlugins(dir.path()); + + EXPECT_EQ(Current::getInstance().pluginHost(), host); + EXPECT_EQ(Current::getInstance().pluginCache(), cache); +} + +TEST(OliveHost, LoadPluginsScansBundleWithoutValidBinary) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + // Minimal .ofx.bundle layout with a file that is not a loadable shared + // object: the scanner must mark it invalid and move on. + const QString arch_dir = dir.filePath(QStringLiteral( + "Fake.ofx.bundle/Contents/Linux-x86-64")); + ASSERT_TRUE(QDir().mkpath(arch_dir)); + QFile fake_binary(dir.filePath(QStringLiteral( + "Fake.ofx.bundle/Contents/Linux-x86-64/Fake.ofx"))); + ASSERT_TRUE(fake_binary.open(QIODevice::WriteOnly)); + fake_binary.write("not a shared object"); + fake_binary.close(); + + EXPECT_NO_THROW(olive::plugin::loadPlugins(dir.path())); + + EXPECT_NE(OFX::Host::PluginCache::getPluginCache(), nullptr); + // The invalid bundle must not have registered any plugin. + for (auto *plug : OFX::Host::PluginCache::getPluginCache()->getPlugins()) { + ASSERT_NE(plug, nullptr); + EXPECT_NE(plug->getIdentifier(), "Fake"); + } +} + +// ============================================================================ +// PluginNode +// +// See the file header: PluginNode needs a real OFX instance and cannot be +// built without a plugin bundle. What remains constructible is the fallback +// input id the node uses when an effect declares no usable source clip. +// ============================================================================ + +TEST(PluginNode, TextureInputFallbackIdIsStable) +{ + EXPECT_EQ(olive::plugin::kTextureInput, QStringLiteral("tex_in")); +} diff --git a/tests/gtest/render_processor_test.cpp b/tests/gtest/render_processor_test.cpp new file mode 100644 index 000000000..5dd1921a5 --- /dev/null +++ b/tests/gtest/render_processor_test.cpp @@ -0,0 +1,781 @@ +/* + * Oak Video Editor - Render Processor & Manager CPU Tests + * Copyright (C) 2026 Oak Team + * + * CPU-only, headless coverage for render pipeline pieces that need no GL + * context, worker process, or audio device: + * - RenderProcessor (ticket plumbing: audio render + clamp + waveform, + * video dry-run with a null renderer, cancelled tickets) + * - RenderManager (RenderVideoParams/RenderAudioParams default plumbing, + * kDryRunInterval) + * - RenderJobTracker (job-time tagged range bookkeeping) + * - SubtitleParams (ASS header generation, XML save/load round trip) + * - ManagedColor (color input/output transform plumbing) + * - Texture (dummy textures constructible without a renderer) + * + * SpscRingBuffer is intentionally not covered here; render_ipc_test.cpp and + * render_workerpool_ipc_test.cpp already exercise it thoroughly. The backend + * string conversions are covered by config_test.cpp. + */ + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "audio/audiovisualwaveform.h" +#include "common/jobtime.h" +#include "common/qtutils.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/globals.h" +#include "node/project.h" +#include "render/job/acceleratedjob.h" +#include "render/managedcolor.h" +#include "render/renderjobtracker.h" +#include "render/rendermanager.h" +#include "render/renderprocessor.h" +#include "render/subtitleparams.h" +#include "render/texture.h" + +namespace +{ + +// A node that emits a constant, deliberately over-range (+2.0) sample buffer +// so RenderProcessor's clamp path has observable work to do. +class ConstantSampleNode : public olive::Node { +public: + ConstantSampleNode() = default; + + NODE_DEFAULT_FUNCTIONS(ConstantSampleNode) + + virtual QString Name() const override + { + return QStringLiteral("Constant Sample Node"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.constant_sample_node"); + } + + virtual QVector Category() const override + { + return {}; + } + + virtual void Value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + + const olive::core::AudioParams ¶ms = globals.aparams(); + const size_t sample_count = + size_t(params.time_to_samples(globals.time().length())); + + olive::core::SampleBuffer buffer(params, sample_count); + for (int ch = 0; ch < buffer.channel_count(); ch++) { + float *data = buffer.data(ch); + for (size_t i = 0; i < sample_count; i++) { + data[i] = 2.0f; + } + } + + table->Push(olive::NodeValue::kSamples, QVariant::fromValue(buffer), + this); + } +}; + +olive::RenderTicketPtr MakeVideoTicket(olive::Node *node) +{ + olive::RenderTicketPtr ticket = std::make_shared(); + ticket->setProperty("node", olive::QtUtils::PtrToValue(node)); + ticket->setProperty("time", QVariant::fromValue(olive::rational(0))); + ticket->setProperty( + "type", QVariant::fromValue(olive::RenderManager::kTypeVideo)); + ticket->setProperty( + "vparam", + QVariant::fromValue(olive::VideoParams(64, 64, olive::rational(1, 30), + olive::core::PixelFormat::U8, + 4))); + ticket->setProperty("aparam", + QVariant::fromValue(olive::core::AudioParams())); + ticket->setProperty("mode", int(olive::RenderMode::kOnline)); + return ticket; +} + +olive::RenderTicketPtr MakeAudioTicket(olive::Node *node, bool waveforms, + bool clamp) +{ + olive::RenderTicketPtr ticket = std::make_shared(); + ticket->setProperty("node", olive::QtUtils::PtrToValue(node)); + ticket->setProperty( + "time", + QVariant::fromValue(olive::TimeRange(olive::rational(0), + olive::rational(1)))); + ticket->setProperty( + "type", QVariant::fromValue(olive::RenderManager::kTypeAudio)); + ticket->setProperty("enablewaveforms", waveforms); + ticket->setProperty("clamp", clamp); + ticket->setProperty( + "aparam", + QVariant::fromValue(olive::core::AudioParams( + 48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P))); + ticket->setProperty( + "vparam", + QVariant::fromValue(olive::VideoParams(64, 64, olive::rational(1, 30), + olive::core::PixelFormat::U8, + 4))); + ticket->setProperty("mode", int(olive::RenderMode::kOnline)); + return ticket; +} + +} // namespace + +// ============================================================================ +// RenderProcessor (null renderer = dry run path) +// ============================================================================ + +TEST(RenderProcessor, AudioTicketRendersAndClampsSamples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = new ConstantSampleNode(); + node->setParent(&project); + + olive::RenderTicketPtr ticket = MakeAudioTicket(node, false, true); + ticket->Start(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + ASSERT_TRUE(ticket->HasResult()); + olive::core::SampleBuffer samples = + ticket->Get().value(); + ASSERT_TRUE(samples.is_allocated()); + EXPECT_EQ(samples.channel_count(), 2); + EXPECT_EQ(samples.sample_count(), size_t(48000)); + + // The node emitted +2.0 everywhere; the ticket requested clamping. + for (int ch = 0; ch < samples.channel_count(); ch++) { + const float *data = samples.data(ch); + for (size_t i = 0; i < samples.sample_count(); i++) { + EXPECT_FLOAT_EQ(data[i], 1.0f); + } + } +} + +TEST(RenderProcessor, AudioTicketWithoutClampKeepsSamples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = new ConstantSampleNode(); + node->setParent(&project); + + olive::RenderTicketPtr ticket = MakeAudioTicket(node, false, false); + ticket->Start(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + ASSERT_TRUE(ticket->HasResult()); + olive::core::SampleBuffer samples = + ticket->Get().value(); + ASSERT_TRUE(samples.is_allocated()); + ASSERT_GT(samples.sample_count(), size_t(0)); + + const float *data = samples.data(0); + for (size_t i = 0; i < samples.sample_count(); i++) { + EXPECT_FLOAT_EQ(data[i], 2.0f); + } +} + +TEST(RenderProcessor, AudioTicketGeneratesWaveformWhenRequested) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = new ConstantSampleNode(); + node->setParent(&project); + + olive::RenderTicketPtr ticket = MakeAudioTicket(node, true, true); + ticket->Start(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + ASSERT_TRUE(ticket->HasResult()); + + const QVariant waveform_var = ticket->property("waveform"); + ASSERT_TRUE(waveform_var.isValid()); + const olive::AudioVisualWaveform waveform = + waveform_var.value(); + EXPECT_EQ(waveform.channel_count(), 2); +} + +TEST(RenderProcessor, AudioTicketWithoutNodeReturnsEmptyBuffer) +{ + olive::RenderTicketPtr ticket = MakeAudioTicket(nullptr, true, true); + ticket->Start(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + // With no node to traverse the processor still finishes with a (null) + // SampleBuffer, and skips both clamping and waveform generation. + ASSERT_TRUE(ticket->HasResult()); + const olive::core::SampleBuffer samples = + ticket->Get().value(); + EXPECT_FALSE(samples.is_allocated()); + EXPECT_FALSE(ticket->property("waveform").isValid()); +} + +TEST(RenderProcessor, VideoTicketWithoutRendererFinishesWithoutResult) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + + olive::RenderTicketPtr ticket = MakeVideoTicket(solid); + ticket->Start(); + + // A null render context is the "dry run": the graph is traversed (Solid + // emits a shader job which is skipped) and the ticket finishes empty. + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->HasResult()); +} + +TEST(RenderProcessor, VideoTicketWithoutNodeFinishesWithoutResult) +{ + olive::RenderTicketPtr ticket = MakeVideoTicket(nullptr); + ticket->Start(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->HasResult()); +} + +TEST(RenderProcessor, CancelledTicketFinishesImmediately) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(&project); + + olive::RenderTicketPtr ticket = MakeVideoTicket(solid); + ticket->Start(); + ticket->Cancel(); + + olive::RenderProcessor::Process(ticket, nullptr, nullptr, nullptr); + + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->HasResult()); +} + +// ============================================================================ +// RenderManager parameter plumbing (no instance required) +// ============================================================================ + +TEST(RenderManagerParams, RenderVideoParamsDefaults) +{ + const olive::VideoParams vparams(1920, 1080, olive::core::PixelFormat::U8, + 4); + const olive::core::AudioParams aparams; + + olive::RenderManager::RenderVideoParams params(nullptr, vparams, aparams, + olive::rational(5), nullptr, + olive::RenderMode::kOnline); + + EXPECT_EQ(params.node, nullptr); + EXPECT_EQ(params.video_params, vparams); + EXPECT_EQ(params.audio_params, aparams); + EXPECT_EQ(params.time, olive::rational(5)); + EXPECT_EQ(params.color_manager, nullptr); + EXPECT_EQ(params.mode, olive::RenderMode::kOnline); + + EXPECT_FALSE(params.use_cache); + EXPECT_EQ(params.return_type, olive::RenderManager::kFrame); + EXPECT_EQ(params.multicam, nullptr); + + EXPECT_TRUE(params.cache_dir.isEmpty()); + EXPECT_TRUE(params.cache_id.isEmpty()); + + EXPECT_EQ(params.force_size, QSize(0, 0)); + EXPECT_EQ(params.force_channel_count, 0); + EXPECT_TRUE(params.force_matrix.isIdentity()); + EXPECT_EQ(int(params.force_format), + int(olive::core::PixelFormat::INVALID)); + EXPECT_TRUE(params.force_color_output == nullptr); + EXPECT_FALSE(params.force_color_transform.is_display()); + EXPECT_TRUE(params.force_color_transform.output().isEmpty()); +} + +TEST(RenderManagerParams, RenderAudioParamsDefaults) +{ + const olive::core::AudioParams aparams( + 48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + const olive::TimeRange range(olive::rational(2), olive::rational(7)); + + olive::RenderManager::RenderAudioParams params(nullptr, range, aparams, + olive::RenderMode::kOffline); + + EXPECT_EQ(params.node, nullptr); + EXPECT_EQ(params.range, range); + EXPECT_EQ(params.audio_params, aparams); + EXPECT_FALSE(params.generate_waveforms); + EXPECT_TRUE(params.clamp); + EXPECT_EQ(params.mode, olive::RenderMode::kOffline); +} + +TEST(RenderManagerParams, DryRunIntervalIsTenSeconds) +{ + EXPECT_EQ(olive::rational(olive::RenderManager::kDryRunInterval), + olive::rational(10)); +} + +// ============================================================================ +// RenderJobTracker +// ============================================================================ + +TEST(RenderJobTracker, EmptyTrackerIsNeverCurrent) +{ + olive::RenderJobTracker tracker; + const olive::JobTime job; + + EXPECT_FALSE(tracker.isCurrent(olive::rational(0), job)); + EXPECT_TRUE( + tracker.getCurrentSubRanges(olive::TimeRange(0, 10), job).isEmpty()); +} + +TEST(RenderJobTracker, InsertedRangeIsCurrentForSameAndNewerJobs) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 10), older); + + // A range rendered at job time T satisfies queries at T and later. + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), older)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), newer)); +} + +TEST(RenderJobTracker, IsCurrentRespectsRangeBoundaries) +{ + olive::RenderJobTracker tracker; + const olive::JobTime job; + + tracker.insert(olive::TimeRange(0, 10), job); + + EXPECT_TRUE(tracker.isCurrent(olive::rational(0), job)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(-1), job)); + // The out point is exclusive. + EXPECT_FALSE(tracker.isCurrent(olive::rational(10), job)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(11), job)); +} + +TEST(RenderJobTracker, ReinsertingSameRangeBumpsJobTime) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 10), older); + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), older)); + + tracker.insert(olive::TimeRange(0, 10), newer); + + // The older job no longer describes the cached content. + EXPECT_FALSE(tracker.isCurrent(olive::rational(5), older)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), newer)); +} + +TEST(RenderJobTracker, InsertSplitsExistingRange) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 10), older); + tracker.insert(olive::TimeRange(4, 6), newer); + + // The original range is split around the new one, keeping its job time. + EXPECT_TRUE(tracker.isCurrent(olive::rational(2), older)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(8), older)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(5), older)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), newer)); +} + +TEST(RenderJobTracker, InsertTrimsOverlappingRangeEnds) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 10), older); + tracker.insert(olive::TimeRange(5, 15), newer); + + EXPECT_TRUE(tracker.isCurrent(olive::rational(2), older)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(7), older)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(7), newer)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(12), newer)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(16), newer)); +} + +TEST(RenderJobTracker, InsertRangeListTagsAllRanges) +{ + olive::RenderJobTracker tracker; + const olive::JobTime job; + + olive::TimeRangeList ranges; + ranges.insert(olive::TimeRange(0, 5)); + ranges.insert(olive::TimeRange(10, 15)); + tracker.insert(ranges, job); + + EXPECT_TRUE(tracker.isCurrent(olive::rational(2), job)); + EXPECT_TRUE(tracker.isCurrent(olive::rational(12), job)); + EXPECT_FALSE(tracker.isCurrent(olive::rational(7), job)); +} + +TEST(RenderJobTracker, GetCurrentSubRangesClipsToQueryRange) +{ + olive::RenderJobTracker tracker; + const olive::JobTime job; + + tracker.insert(olive::TimeRange(0, 10), job); + + const olive::TimeRangeList sub = + tracker.getCurrentSubRanges(olive::TimeRange(4, 20), job); + ASSERT_EQ(sub.size(), 1); + EXPECT_EQ(*sub.begin(), olive::TimeRange(4, 10)); +} + +TEST(RenderJobTracker, GetCurrentSubRangesIgnoresNewerJobs) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 10), newer); + + // Querying with an older job time sees nothing current. + EXPECT_TRUE( + tracker.getCurrentSubRanges(olive::TimeRange(0, 10), older).isEmpty()); +} + +TEST(RenderJobTracker, GetCurrentSubRangesMergesAdjacentJobs) +{ + olive::RenderJobTracker tracker; + const olive::JobTime older; + const olive::JobTime newer; + + tracker.insert(olive::TimeRange(0, 5), older); + tracker.insert(olive::TimeRange(5, 10), newer); + + // Both jobs are current for `newer`; touching ranges merge into one. + const olive::TimeRangeList sub = + tracker.getCurrentSubRanges(olive::TimeRange(0, 10), newer); + ASSERT_EQ(sub.size(), 1); + EXPECT_EQ(*sub.begin(), olive::TimeRange(0, 10)); +} + +TEST(RenderJobTracker, ClearDropsAllJobs) +{ + olive::RenderJobTracker tracker; + const olive::JobTime job; + + tracker.insert(olive::TimeRange(0, 10), job); + EXPECT_TRUE(tracker.isCurrent(olive::rational(5), job)); + + tracker.clear(); + EXPECT_FALSE(tracker.isCurrent(olive::rational(5), job)); +} + +// ============================================================================ +// SubtitleParams +// ============================================================================ + +TEST(SubtitleParams, DefaultsAreEmptyEnabledStreamZero) +{ + const olive::SubtitleParams params; + + EXPECT_FALSE(params.is_valid()); + EXPECT_EQ(params.duration(), olive::rational(0)); + EXPECT_EQ(params.stream_index(), 0); + EXPECT_TRUE(params.enabled()); +} + +TEST(SubtitleParams, DurationFollowsLastSubtitle) +{ + olive::SubtitleParams params; + params.push_back( + olive::Subtitle(olive::TimeRange(0, 2), QStringLiteral("one"))); + params.push_back( + olive::Subtitle(olive::TimeRange(3, 5), QStringLiteral("two"))); + + EXPECT_TRUE(params.is_valid()); + EXPECT_EQ(params.duration(), olive::rational(5)); + + params.set_stream_index(3); + params.set_enabled(false); + EXPECT_EQ(params.stream_index(), 3); + EXPECT_FALSE(params.enabled()); +} + +TEST(SubtitleParams, SubtitleAccessorsRoundTrip) +{ + olive::Subtitle sub(olive::TimeRange(1, 4), QStringLiteral("hello")); + EXPECT_EQ(sub.time(), olive::TimeRange(1, 4)); + EXPECT_EQ(sub.text(), QStringLiteral("hello")); + + sub.set_time(olive::TimeRange(2, 6)); + sub.set_text(QStringLiteral("world")); + EXPECT_EQ(sub.time(), olive::TimeRange(2, 6)); + EXPECT_EQ(sub.text(), QStringLiteral("world")); + + const olive::Subtitle def; + EXPECT_TRUE(def.text().isEmpty()); +} + +TEST(SubtitleParams, GenerateAssHeaderContainsRequiredSections) +{ + const QString header = olive::SubtitleParams::GenerateASSHeader(); + + EXPECT_TRUE(header.contains(QStringLiteral("[Script Info]\r\n"))); + EXPECT_TRUE(header.contains(QStringLiteral("ScriptType: v4.00+\r\n"))); + EXPECT_TRUE(header.contains(QStringLiteral("PlayResX: 384\r\n"))); + EXPECT_TRUE(header.contains(QStringLiteral("PlayResY: 288\r\n"))); + EXPECT_TRUE( + header.contains(QStringLiteral("ScaledBorderAndShadow: yes\r\n"))); + EXPECT_TRUE(header.contains(QStringLiteral("[V4+ Styles]\r\n"))); + EXPECT_TRUE(header.contains(QStringLiteral("Style: Default,Arial,16,"))); + EXPECT_TRUE( + header.contains(QStringLiteral("&Hffffff,&Hffffff,&H0,&H0,"))); + EXPECT_TRUE(header.contains(QStringLiteral("[Events]\r\n"))); + EXPECT_TRUE(header.contains( + QStringLiteral("Format: Layer, Start, End, Style, Name, MarginL, " + "MarginR, MarginV, Effect, Text"))); + EXPECT_TRUE(header.endsWith(QStringLiteral("\r\n"))); +} + +TEST(SubtitleParams, SaveLoadRoundTrip) +{ + olive::SubtitleParams params; + params.set_stream_index(2); + params.set_enabled(false); + params.push_back(olive::Subtitle( + olive::TimeRange(olive::rational(0), olive::rational(1, 2)), + QStringLiteral("Hello, world!"))); + params.push_back(olive::Subtitle( + olive::TimeRange(olive::rational(3, 4), olive::rational(2)), + QStringLiteral("Second & more"))); + + QString xml; + { + QXmlStreamWriter writer(&xml); + writer.writeStartElement(QStringLiteral("root")); + params.Save(&writer); + writer.writeEndElement(); + } + + olive::SubtitleParams loaded; + // Pre-existing content must be cleared by Load. + loaded.push_back( + olive::Subtitle(olive::TimeRange(9, 10), QStringLiteral("junk"))); + + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); // position on + loaded.Load(&reader); + + EXPECT_EQ(loaded.stream_index(), 2); + EXPECT_FALSE(loaded.enabled()); + ASSERT_EQ(loaded.size(), size_t(2)); + EXPECT_EQ(loaded.at(0).time().in(), olive::rational(0)); + EXPECT_EQ(loaded.at(0).time().out(), olive::rational(1, 2)); + EXPECT_EQ(loaded.at(0).text(), QStringLiteral("Hello, world!")); + EXPECT_EQ(loaded.at(1).time().in(), olive::rational(3, 4)); + EXPECT_EQ(loaded.at(1).time().out(), olive::rational(2)); + EXPECT_EQ(loaded.at(1).text(), QStringLiteral("Second & more")); +} + +// ============================================================================ +// ManagedColor +// ============================================================================ + +TEST(ManagedColor, DefaultConstructionHasNoTransforms) +{ + const olive::ManagedColor color; + + EXPECT_FLOAT_EQ(color.red(), 0.0f); + EXPECT_FLOAT_EQ(color.green(), 0.0f); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); + EXPECT_FLOAT_EQ(color.alpha(), 0.0f); + EXPECT_TRUE(color.color_input().isEmpty()); + EXPECT_FALSE(color.color_output().is_display()); + EXPECT_TRUE(color.color_output().output().isEmpty()); +} + +TEST(ManagedColor, RgbaConstructorPreservesChannels) +{ + const olive::ManagedColor color(0.25, 0.5, 0.75, 0.5); + + EXPECT_FLOAT_EQ(color.red(), 0.25f); + EXPECT_FLOAT_EQ(color.green(), 0.5f); + EXPECT_FLOAT_EQ(color.blue(), 0.75f); + EXPECT_FLOAT_EQ(color.alpha(), 0.5f); +} + +TEST(ManagedColor, ColorCopyConstructorPreservesChannels) +{ + const olive::core::Color base(0.1f, 0.2f, 0.3f, 1.0f); + const olive::ManagedColor color(base); + + EXPECT_FLOAT_EQ(color.red(), base.red()); + EXPECT_FLOAT_EQ(color.green(), base.green()); + EXPECT_FLOAT_EQ(color.blue(), base.blue()); + EXPECT_FLOAT_EQ(color.alpha(), base.alpha()); +} + +TEST(ManagedColor, RawDataConstructorDecodesU8) +{ + const char data[4] = { char(255), char(128), char(0), char(64) }; + const olive::ManagedColor color(data, olive::core::PixelFormat::U8, 4); + + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_NEAR(color.green(), 128.0 / 255.0, 1e-6); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); + EXPECT_NEAR(color.alpha(), 64.0 / 255.0, 1e-6); +} + +TEST(ManagedColor, ColorInputAndOutputRoundTrip) +{ + olive::ManagedColor color; + + color.set_color_input(QStringLiteral("linear")); + EXPECT_EQ(color.color_input(), QStringLiteral("linear")); + + color.set_color_output(olive::ColorTransform(QStringLiteral("sRGB"))); + EXPECT_FALSE(color.color_output().is_display()); + EXPECT_EQ(color.color_output().output(), QStringLiteral("sRGB")); + + color.set_color_output(olive::ColorTransform(QStringLiteral("sRGB"), + QStringLiteral("Filmic"), + QStringLiteral("None"))); + EXPECT_TRUE(color.color_output().is_display()); + EXPECT_EQ(color.color_output().display(), QStringLiteral("sRGB")); + EXPECT_EQ(color.color_output().view(), QStringLiteral("Filmic")); + EXPECT_EQ(color.color_output().look(), QStringLiteral("None")); +} + +// ============================================================================ +// Texture (dummy, no renderer) +// ============================================================================ + +TEST(RenderTexture, DummyTextureExposesParams) +{ + const olive::VideoParams params(320, 240, olive::core::PixelFormat::U8, 4); + olive::Texture texture(params); + + EXPECT_TRUE(texture.IsDummy()); + EXPECT_EQ(texture.renderer(), nullptr); + EXPECT_EQ(texture.params(), params); + EXPECT_EQ(texture.width(), 320); + EXPECT_EQ(texture.height(), 240); + EXPECT_EQ(texture.channel_count(), 4); + EXPECT_EQ(texture.divider(), 1); + EXPECT_EQ(texture.pixel_aspect_ratio(), olive::rational(1)); + EXPECT_EQ(texture.virtual_resolution(), QVector2D(320, 240)); + EXPECT_EQ(int(texture.format()), int(olive::core::PixelFormat::U8)); + EXPECT_FALSE(texture.id().isValid()); + EXPECT_FALSE(texture.IsJob()); + EXPECT_EQ(texture.job(), nullptr); + EXPECT_TRUE(texture.frame() == nullptr); +} + +TEST(RenderTexture, DummyTextureHonorsDivider) +{ + const olive::VideoParams params(320, 240, olive::core::PixelFormat::U8, 4, + olive::rational(1), + olive::VideoParams::kInterlaceNone, 2); + const olive::Texture texture(params); + + EXPECT_EQ(texture.divider(), 2); + EXPECT_EQ(texture.width(), 160); + EXPECT_EQ(texture.height(), 120); +} + +TEST(RenderTexture, JobTextureCarriesJobAndParams) +{ + const olive::VideoParams params(64, 64, olive::core::PixelFormat::F32, 4); + + olive::AcceleratedJob job; + job.Insert(QStringLiteral("value_in"), + olive::NodeValue(olive::NodeValue::kFloat, 2.5)); + + const olive::TexturePtr texture = olive::Texture::Job(params, job); + ASSERT_TRUE(texture != nullptr); + EXPECT_TRUE(texture->IsDummy()); + EXPECT_TRUE(texture->IsJob()); + ASSERT_TRUE(texture->job() != nullptr); + EXPECT_TRUE( + texture->job()->GetValues().contains(QStringLiteral("value_in"))); + EXPECT_EQ(texture->job()->Get(QStringLiteral("value_in")).toDouble(), 2.5); + EXPECT_EQ(texture->params(), params); +} + +TEST(RenderTexture, ToJobCreatesJobTextureWithSameParams) +{ + const olive::VideoParams params(128, 72, olive::core::PixelFormat::U8, 4); + olive::Texture dummy(params); + + const olive::AcceleratedJob job; + const olive::TexturePtr job_tex = dummy.toJob(job); + + ASSERT_TRUE(job_tex != nullptr); + EXPECT_FALSE(dummy.IsJob()); + EXPECT_TRUE(job_tex->IsJob()); + EXPECT_EQ(job_tex->params(), dummy.params()); +} + +TEST(RenderTexture, UploadDownloadOnDummyAreNoOps) +{ + const olive::VideoParams params(16, 16, olive::core::PixelFormat::U8, 4); + olive::Texture texture(params); + + // With no renderer backend both calls must return without touching data. + uint8_t buffer[16 * 16 * 4]; + memset(buffer, 0xAB, sizeof(buffer)); + texture.Upload(buffer, 16 * 4); + texture.Download(buffer, 16 * 4); + EXPECT_EQ(buffer[0], uint8_t(0xAB)); +} + +TEST(RenderTexture, DefaultInterpolationIsMipmappedLinear) +{ + EXPECT_EQ(int(olive::Texture::kDefaultInterpolation), + int(olive::Texture::kMipmappedLinear)); +}