From 4302a0aca2dbc33cce45f0d9e386086b40ece255 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Fri, 17 Jul 2026 06:24:35 +0800 Subject: [PATCH] tests: coverage round 7 (audio, distort, filter/keying, math/transition nodes) - node_audio_test: PanNode/VolumeNode sample math, TimeInput, ValueNode - node_distort_test: transform/crop/flip/cornerpin/mask/ripple/swirl/ tile/wave inputs, shader code, value jobs, resolution autoscale math - node_filter_keying_test: opacity/blur/dropshadow/mosaic/stroke jobs, chroma/color-difference key and despill value composition - node_math_transition_test: TrigonometryNode ops, MergeNode, TransitionBlock offsets/progress/crossfade math, SubtitleBlock, traverser globals and table caching --- tests/gtest/CMakeLists.txt | 4 + tests/gtest/node_audio_test.cpp | 802 ++++++++++ tests/gtest/node_distort_test.cpp | 1779 +++++++++++++++++++++ tests/gtest/node_filter_keying_test.cpp | 1503 +++++++++++++++++ tests/gtest/node_math_transition_test.cpp | 1102 +++++++++++++ 5 files changed, 5190 insertions(+) create mode 100644 tests/gtest/node_audio_test.cpp create mode 100644 tests/gtest/node_distort_test.cpp create mode 100644 tests/gtest/node_filter_keying_test.cpp create mode 100644 tests/gtest/node_math_transition_test.cpp diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 778aef436..2c949d8fa 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -71,6 +71,10 @@ add_executable(olive-gtest node_generator_test.cpp render_misc_test.cpp multicam_serializer_test.cpp + node_audio_test.cpp + node_distort_test.cpp + node_filter_keying_test.cpp + node_math_transition_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/node_audio_test.cpp b/tests/gtest/node_audio_test.cpp new file mode 100644 index 000000000..8021b1aae --- /dev/null +++ b/tests/gtest/node_audio_test.cpp @@ -0,0 +1,802 @@ +#include + +#include + +#include +#include + +#include "node/audio/pan/pan.h" +#include "node/audio/volume/volume.h" +#include "node/color/colormanager/colormanager.h" +#include "node/globals.h" +#include "node/input/time/timeinput.h" +#include "node/input/value/valuenode.h" +#include "node/keyframe.h" +#include "node/project.h" +#include "node/traverser.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "olive/core/util/rational.h" +#include "render/job/samplejob.h" +#include "widget/slider/floatslider.h" + +namespace +{ + +// Minimal node that emits a single configurable value, used to feed the +// samples input of the audio nodes without any decoder or audio hardware. +class ConstantValueNode : public olive::Node { +public: + ConstantValueNode() = default; + + NODE_DEFAULT_FUNCTIONS(ConstantValueNode) + + virtual QString Name() const override + { + return QStringLiteral("Test Constant"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.constant"); + } + + virtual QVector Category() const override + { + return { kCategoryGenerator }; + } + + void SetOutput(const olive::NodeValue &value) + { + output_ = value; + } + + virtual void Value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + Q_UNUSED(globals) + + table->Push(output_); + } + +private: + olive::NodeValue output_; +}; + +// Traverser that resolves SampleJobs on the CPU (no audio hardware) so the +// non-static (job) paths of the audio nodes can be verified end to end. +class SampleResolvingTraverser : public olive::NodeTraverser { +public: + void Resolve(olive::NodeValue &value) + { + ResolveJobs(value); + } + +protected: + virtual olive::core::SampleBuffer + CreateSampleBuffer(const olive::core::AudioParams ¶ms, + int sample_count) override + { + return olive::core::SampleBuffer(params, size_t(sample_count)); + } + + virtual void ProcessSamples(olive::core::SampleBuffer &destination, + const olive::Node *node, + const olive::TimeRange &range, + const olive::SampleJob &job) override + { + Q_UNUSED(range) + + for (size_t i = 0; i < destination.sample_count(); i++) { + node->ProcessSamples(job.GetValues(), job.samples(), destination, + int(i)); + } + } +}; + +template T *AddNode(olive::Project *project) +{ + T *node = new T(); + node->setParent(project); + return node; +} + +ConstantValueNode *AddConstant(olive::Project *project, + const olive::NodeValue &value) +{ + auto *node = new ConstantValueNode(); + node->setParent(project); + node->SetOutput(value); + 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; +} + +// A fresh traverser per call: NodeTraverser caches tables per node+range, so +// reusing one would return stale results after the node's parameters change. +olive::NodeValueTable GenerateTableAt(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))); +} + +olive::core::AudioParams StereoParams() +{ + return olive::core::AudioParams(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); +} + +olive::core::AudioParams MonoParams() +{ + return olive::core::AudioParams(48000, olive::core::kChannelLayoutMono, + olive::core::SampleFormat::F32P); +} + +// Creates a stereo buffer with the given per-channel samples. Both channels +// must have the same number of samples. +olive::core::SampleBuffer MakeStereoBuffer(const std::vector &channel0, + const std::vector &channel1) +{ + olive::core::SampleBuffer buffer(StereoParams(), channel0.size()); + for (size_t i = 0; i < channel0.size(); i++) { + buffer.data(0)[i] = channel0[i]; + } + for (size_t i = 0; i < channel1.size(); i++) { + buffer.data(1)[i] = channel1[i]; + } + return buffer; +} + +olive::core::SampleBuffer MakeMonoBuffer(const std::vector &samples) +{ + olive::core::SampleBuffer buffer(MonoParams(), samples.size()); + for (size_t i = 0; i < samples.size(); i++) { + buffer.data(0)[i] = samples[i]; + } + return buffer; +} + +olive::NodeValue SampleValue(const olive::core::SampleBuffer &buffer) +{ + return olive::NodeValue(olive::NodeValue::kSamples, + QVariant::fromValue(buffer)); +} + +// Connects a stereo buffer {1,2,3,4}/{5,6,7,8} to the node's samples input. +ConstantValueNode *ConnectTestSamples(olive::Project *project, + olive::Node *node, + const QString &samples_input) +{ + ConstantValueNode *samples = AddConstant( + project, SampleValue(MakeStereoBuffer({ 1.0f, 2.0f, 3.0f, 4.0f }, + { 5.0f, 6.0f, 7.0f, 8.0f }))); + olive::Node::ConnectEdge(samples, olive::NodeInput(node, samples_input)); + return samples; +} + +} // namespace + +TEST(PanNode, Metadata) +{ + olive::PanNode pan; + + EXPECT_EQ(pan.Name(), QStringLiteral("Pan")); + EXPECT_EQ(pan.id(), QStringLiteral("org.olivevideoeditor.Olive.pan")); + EXPECT_FALSE(pan.Description().isEmpty()); + EXPECT_TRUE(pan.Category().contains(olive::Node::kCategoryFilter)); + + // Registered as an audio effect with the samples input as effect input + EXPECT_TRUE(pan.GetFlags() & olive::Node::kAudioEffect); + EXPECT_EQ(pan.GetEffectInputID(), olive::PanNode::kSamplesInput); +} + +TEST(PanNode, InputDefaults) +{ + olive::PanNode pan; + + EXPECT_EQ(pan.GetInputDataType(olive::PanNode::kSamplesInput), + olive::NodeValue::kSamples); + EXPECT_FALSE(pan.IsInputKeyframable(olive::PanNode::kSamplesInput)); + EXPECT_TRUE(pan.IsInputConnectable(olive::PanNode::kSamplesInput)); + + EXPECT_EQ(pan.GetInputDataType(olive::PanNode::kPanningInput), + olive::NodeValue::kFloat); + EXPECT_TRUE(pan.IsInputKeyframable(olive::PanNode::kPanningInput)); + EXPECT_DOUBLE_EQ( + pan.GetStandardValue(olive::PanNode::kPanningInput).toDouble(), 0.0); + EXPECT_DOUBLE_EQ(pan.GetInputProperty(olive::PanNode::kPanningInput, + QStringLiteral("min")) + .toDouble(), + -1.0); + EXPECT_DOUBLE_EQ(pan.GetInputProperty(olive::PanNode::kPanningInput, + QStringLiteral("max")) + .toDouble(), + 1.0); + EXPECT_EQ(int(pan.GetInputProperty(olive::PanNode::kPanningInput, + QStringLiteral("view")) + .toInt()), + int(olive::FloatSlider::kPercentage)); +} + +TEST(PanNode, RetranslateSetsInputNames) +{ + olive::PanNode pan; + + pan.Retranslate(); + + EXPECT_EQ(pan.GetInputName(olive::PanNode::kSamplesInput), + QStringLiteral("Samples")); + EXPECT_EQ(pan.GetInputName(olive::PanNode::kPanningInput), + QStringLiteral("Pan")); +} + +TEST(PanNode, StaticCenterPanLeavesSamplesUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + ConnectTestSamples(&project, pan, olive::PanNode::kSamplesInput); + + // Pan 0 is a no-op, but the (unmodified) buffer is still pushed + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1)); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(PanNode, StaticRightPanAttenuatesLeftChannel) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + pan->SetStandardValue(olive::PanNode::kPanningInput, 0.5); + ConnectTestSamples(&project, pan, olive::PanNode::kSamplesInput); + + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1) * 0.5f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(PanNode, StaticLeftPanAttenuatesRightChannel) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + pan->SetStandardValue(olive::PanNode::kPanningInput, -0.5); + ConnectTestSamples(&project, pan, olive::PanNode::kSamplesInput); + + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1)); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5) * 0.5f); + } +} + +TEST(PanNode, StaticFullRightPanSilencesLeftChannel) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + pan->SetStandardValue(olive::PanNode::kPanningInput, 1.0); + ConnectTestSamples(&project, pan, olive::PanNode::kSamplesInput); + + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], 0.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(PanNode, NonStereoSamplesPassThroughUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + pan->SetStandardValue(olive::PanNode::kPanningInput, 0.5); + + ConstantValueNode *samples = AddConstant( + &project, SampleValue(MakeMonoBuffer({ 1.0f, -2.0f, 3.0f }))); + olive::Node::ConnectEdge( + samples, olive::NodeInput(pan, olive::PanNode::kSamplesInput)); + + // Pan only supports stereo: a mono buffer is pushed through untouched + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.audio_params().channel_count(), 1); + ASSERT_EQ(out.sample_count(), 3u); + EXPECT_FLOAT_EQ(out.data(0)[0], 1.0f); + EXPECT_FLOAT_EQ(out.data(0)[1], -2.0f); + EXPECT_FLOAT_EQ(out.data(0)[2], 3.0f); +} + +TEST(PanNode, NoSamplesInputProducesNoOutput) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + pan->SetStandardValue(olive::PanNode::kPanningInput, 0.5); + + // Without an allocated buffer on the samples input, Value() pushes nothing + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + EXPECT_EQ(table.Get(olive::NodeValue::kSamples).type(), + olive::NodeValue::kNone); +} + +TEST(PanNode, KeyframedPanProducesSampleJobButLosesPanValue) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::PanNode *pan = AddNode(&project); + ConnectTestSamples(&project, pan, olive::PanNode::kSamplesInput); + + // A non-static (keyframed) pan makes Value() push a SampleJob instead of + // processing the buffer immediately + pan->SetInputIsKeyframing(olive::PanNode::kPanningInput, true); + AddKey(pan, olive::PanNode::kPanningInput, olive::core::rational(0), 1.0); + + const olive::NodeValueTable table = GenerateTableAt(pan, olive::core::rational(0)); + olive::NodeValue result = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(result.type(), olive::NodeValue::kSamples); + ASSERT_TRUE(result.canConvert()); + + // NOTE: Unlike VolumeNode, PanNode::Value() never inserts the panning + // value into the SampleJob, so the job's value map is empty and + // ProcessSamples() always sees a pan of 0 (a plain copy). The production + // RenderProcessor only re-evaluates inputs present in the job, so + // keyframed pan is silently ignored (suspected bug, documented here). + const olive::SampleJob job = result.value(); + EXPECT_FALSE(job.GetValues().contains(olive::PanNode::kPanningInput)); + + SampleResolvingTraverser resolver; + resolver.Resolve(result); + + const olive::core::SampleBuffer out = result.toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1)); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(PanNode, ProcessSamplesAppliesPanPerSample) +{ + olive::PanNode pan; + + olive::NodeValueRow row; + row.insert(olive::PanNode::kPanningInput, + olive::NodeValue(olive::NodeValue::kFloat, 0.5)); + + olive::core::SampleBuffer input(StereoParams(), 2); + olive::core::SampleBuffer output(StereoParams(), 2); + input.data(0)[0] = 1.5f; + input.data(0)[1] = -2.0f; + input.data(1)[0] = 0.25f; + input.data(1)[1] = 8.0f; + + pan.ProcessSamples(row, input, output, 0); + pan.ProcessSamples(row, input, output, 1); + + // Panning right attenuates the left channel only + EXPECT_FLOAT_EQ(output.data(0)[0], 0.75f); + EXPECT_FLOAT_EQ(output.data(0)[1], -1.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], 0.25f); + EXPECT_FLOAT_EQ(output.data(1)[1], 8.0f); + + // Panning left attenuates the right channel only + row.insert(olive::PanNode::kPanningInput, + olive::NodeValue(olive::NodeValue::kFloat, -0.25)); + pan.ProcessSamples(row, input, output, 0); + + EXPECT_FLOAT_EQ(output.data(0)[0], 1.5f); + EXPECT_FLOAT_EQ(output.data(1)[0], 0.1875f); +} + +TEST(PanNode, ProcessSamplesWithoutPanValueCopiesInput) +{ + olive::PanNode pan; + + // No panning value in the row: samples are copied unchanged + olive::NodeValueRow row; + + olive::core::SampleBuffer input(StereoParams(), 1); + olive::core::SampleBuffer output(StereoParams(), 1); + input.data(0)[0] = 3.0f; + input.data(1)[0] = -4.0f; + + pan.ProcessSamples(row, input, output, 0); + + EXPECT_FLOAT_EQ(output.data(0)[0], 3.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], -4.0f); +} + +TEST(VolumeNode, Metadata) +{ + olive::VolumeNode volume; + + EXPECT_EQ(volume.Name(), QStringLiteral("Volume")); + EXPECT_EQ(volume.id(), + QStringLiteral("org.olivevideoeditor.Olive.volume")); + EXPECT_FALSE(volume.Description().isEmpty()); + EXPECT_TRUE(volume.Category().contains(olive::Node::kCategoryFilter)); + + EXPECT_TRUE(volume.GetFlags() & olive::Node::kAudioEffect); + EXPECT_EQ(volume.GetEffectInputID(), olive::VolumeNode::kSamplesInput); +} + +TEST(VolumeNode, InputDefaults) +{ + olive::VolumeNode volume; + + EXPECT_EQ(volume.GetInputDataType(olive::VolumeNode::kSamplesInput), + olive::NodeValue::kSamples); + EXPECT_FALSE(volume.IsInputKeyframable(olive::VolumeNode::kSamplesInput)); + + EXPECT_EQ(volume.GetInputDataType(olive::VolumeNode::kVolumeInput), + olive::NodeValue::kFloat); + EXPECT_TRUE(volume.IsInputKeyframable(olive::VolumeNode::kVolumeInput)); + EXPECT_DOUBLE_EQ( + volume.GetStandardValue(olive::VolumeNode::kVolumeInput).toDouble(), + 1.0); + EXPECT_DOUBLE_EQ(volume.GetInputProperty(olive::VolumeNode::kVolumeInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_EQ(int(volume.GetInputProperty(olive::VolumeNode::kVolumeInput, + QStringLiteral("view")) + .toInt()), + int(olive::FloatSlider::kDecibel)); +} + +TEST(VolumeNode, RetranslateSetsInputNames) +{ + olive::VolumeNode volume; + + volume.Retranslate(); + + EXPECT_EQ(volume.GetInputName(olive::VolumeNode::kSamplesInput), + QStringLiteral("Samples")); + EXPECT_EQ(volume.GetInputName(olive::VolumeNode::kVolumeInput), + QStringLiteral("Volume")); +} + +TEST(VolumeNode, StaticVolumeScalesSamples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::VolumeNode *volume = AddNode(&project); + volume->SetStandardValue(olive::VolumeNode::kVolumeInput, 2.0); + ConnectTestSamples(&project, volume, olive::VolumeNode::kSamplesInput); + + const olive::NodeValueTable table = GenerateTableAt(volume, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1) * 2.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5) * 2.0f); + } +} + +TEST(VolumeNode, StaticUnityVolumeLeavesSamplesUnchanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::VolumeNode *volume = AddNode(&project); + volume->SetStandardValue(olive::VolumeNode::kVolumeInput, 1.0); + ConnectTestSamples(&project, volume, olive::VolumeNode::kSamplesInput); + + // Volume 1 is a no-op, but the (unmodified) buffer is still pushed + const olive::NodeValueTable table = GenerateTableAt(volume, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1)); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5)); + } +} + +TEST(VolumeNode, StaticZeroVolumeSilencesSamples) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::VolumeNode *volume = AddNode(&project); + volume->SetStandardValue(olive::VolumeNode::kVolumeInput, 0.0); + ConnectTestSamples(&project, volume, olive::VolumeNode::kSamplesInput); + + const olive::NodeValueTable table = GenerateTableAt(volume, olive::core::rational(0)); + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], 0.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], 0.0f); + } +} + +TEST(VolumeNode, KeyframedVolumeProducesSampleJobWithInterpolatedValue) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::VolumeNode *volume = AddNode(&project); + ConnectTestSamples(&project, volume, olive::VolumeNode::kSamplesInput); + + // A non-static (keyframed) volume makes Value() push a SampleJob carrying + // the volume value instead of processing the buffer immediately + volume->SetInputIsKeyframing(olive::VolumeNode::kVolumeInput, true); + AddKey(volume, olive::VolumeNode::kVolumeInput, olive::core::rational(0), + 1.0); + AddKey(volume, olive::VolumeNode::kVolumeInput, olive::core::rational(1), + 3.0); + + // At t=0.5 the linear ramp 1.0 -> 3.0 interpolates to 2.0 + const olive::NodeValueTable table = GenerateTableAt(volume, olive::core::rational(1, 2)); + olive::NodeValue result = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(result.type(), olive::NodeValue::kSamples); + ASSERT_TRUE(result.canConvert()); + + const olive::SampleJob job = result.value(); + ASSERT_TRUE(job.GetValues().contains(olive::VolumeNode::kVolumeInput)); + EXPECT_DOUBLE_EQ(job.Get(olive::VolumeNode::kVolumeInput).toDouble(), 2.0); + + // Resolve the job on the CPU and verify the scaled samples + SampleResolvingTraverser resolver; + resolver.Resolve(result); + + const olive::core::SampleBuffer out = result.toSamples(); + ASSERT_TRUE(out.is_allocated()); + ASSERT_EQ(out.sample_count(), 4u); + for (int i = 0; i < 4; i++) { + EXPECT_FLOAT_EQ(out.data(0)[i], float(i + 1) * 2.0f); + EXPECT_FLOAT_EQ(out.data(1)[i], float(i + 5) * 2.0f); + } +} + +TEST(VolumeNode, ProcessSamplesMultipliesPerSample) +{ + olive::VolumeNode volume; + + olive::NodeValueRow row; + row.insert(olive::VolumeNode::kVolumeInput, + olive::NodeValue(olive::NodeValue::kFloat, 2.0)); + + olive::core::SampleBuffer input(StereoParams(), 2); + olive::core::SampleBuffer output(StereoParams(), 2); + input.data(0)[0] = 1.5f; + input.data(0)[1] = -2.0f; + input.data(1)[0] = 0.25f; + input.data(1)[1] = 8.0f; + + volume.ProcessSamples(row, input, output, 0); + volume.ProcessSamples(row, input, output, 1); + + EXPECT_FLOAT_EQ(output.data(0)[0], 3.0f); + EXPECT_FLOAT_EQ(output.data(0)[1], -4.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], 0.5f); + EXPECT_FLOAT_EQ(output.data(1)[1], 16.0f); +} + +TEST(VolumeNode, ProcessSamplesWithoutVolumeLeavesOutputUntouched) +{ + olive::VolumeNode volume; + + // Neither the samples nor the volume input carries a number: the output + // must not be written + olive::NodeValueRow row; + + olive::core::SampleBuffer input(StereoParams(), 1); + olive::core::SampleBuffer output(StereoParams(), 1); + input.data(0)[0] = 10.0f; + output.data(0)[0] = 123.0f; + output.data(1)[0] = 45.0f; + + volume.ProcessSamples(row, input, output, 0); + + EXPECT_FLOAT_EQ(output.data(0)[0], 123.0f); + EXPECT_FLOAT_EQ(output.data(1)[0], 45.0f); +} + +TEST(TimeInput, Metadata) +{ + olive::TimeInput time; + + EXPECT_EQ(time.Name(), QStringLiteral("Time")); + EXPECT_EQ(time.id(), QStringLiteral("org.olivevideoeditor.Olive.time")); + EXPECT_FALSE(time.Description().isEmpty()); + EXPECT_TRUE(time.Category().contains(olive::Node::kCategoryTime)); +} + +TEST(TimeInput, ValuePushesCurrentTimeInSeconds) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::TimeInput *time = AddNode(&project); + + olive::NodeValueTable table = GenerateTableAt(time, olive::core::rational(0)); + olive::NodeValue result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(result.toDouble(), 0.0); + + table = GenerateTableAt(time, olive::core::rational(5, 2)); + result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(result.toDouble(), 2.5); +} + +TEST(ValueNode, Metadata) +{ + olive::ValueNode value; + + EXPECT_EQ(value.Name(), QStringLiteral("Value")); + EXPECT_EQ(value.id(), QStringLiteral("org.olivevideoeditor.Olive.value")); + EXPECT_FALSE(value.Description().isEmpty()); + EXPECT_TRUE(value.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(ValueNode, InputDefaults) +{ + olive::ValueNode value; + + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kTypeInput), + olive::NodeValue::kCombo); + EXPECT_FALSE(value.IsInputConnectable(olive::ValueNode::kTypeInput)); + EXPECT_FALSE(value.IsInputKeyframable(olive::ValueNode::kTypeInput)); + EXPECT_EQ(value.GetStandardValue(olive::ValueNode::kTypeInput).toInt(), 0); + + // The value input starts out as a float (the first supported type) + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kFloat); + EXPECT_FALSE(value.IsInputConnectable(olive::ValueNode::kValueInput)); + EXPECT_TRUE(value.IsInputKeyframable(olive::ValueNode::kValueInput)); +} + +TEST(ValueNode, RetranslateSetsInputNamesAndTypeCombo) +{ + olive::ValueNode value; + + value.Retranslate(); + + EXPECT_EQ(value.GetInputName(olive::ValueNode::kTypeInput), + QStringLiteral("Type")); + EXPECT_EQ(value.GetInputName(olive::ValueNode::kValueInput), + QStringLiteral("Value")); + + // The type combo lists the pretty name of every supported type, in order + const QStringList types = + value.GetInputProperty(olive::ValueNode::kTypeInput, + QStringLiteral("combo_str")) + .toStringList(); + ASSERT_EQ(types.size(), 11); + EXPECT_EQ(types.at(0), QStringLiteral("Float")); + EXPECT_EQ(types.at(1), QStringLiteral("Integer")); + EXPECT_EQ(types.at(2), QStringLiteral("Rational")); + EXPECT_EQ(types.at(3), QStringLiteral("Vector 2D")); + EXPECT_EQ(types.at(4), QStringLiteral("Vector 3D")); + EXPECT_EQ(types.at(5), QStringLiteral("Vector 4D")); + EXPECT_EQ(types.at(6), QStringLiteral("Color")); + EXPECT_EQ(types.at(7), QStringLiteral("Text")); + EXPECT_EQ(types.at(8), QStringLiteral("Matrix")); + EXPECT_EQ(types.at(9), QStringLiteral("Font")); + EXPECT_EQ(types.at(10), QStringLiteral("Boolean")); +} + +TEST(ValueNode, ChangingTypeSwitchesValueInputDataType) +{ + olive::ValueNode value; + + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kFloat); + + value.SetStandardValue(olive::ValueNode::kTypeInput, 1); + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kInt); + + value.SetStandardValue(olive::ValueNode::kTypeInput, 3); + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kVec2); + + value.SetStandardValue(olive::ValueNode::kTypeInput, 6); + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kColor); + + value.SetStandardValue(olive::ValueNode::kTypeInput, 10); + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kBoolean); + + value.SetStandardValue(olive::ValueNode::kTypeInput, 0); + EXPECT_EQ(value.GetInputDataType(olive::ValueNode::kValueInput), + olive::NodeValue::kFloat); +} + +TEST(ValueNode, ValuePassesThroughFloat) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::ValueNode *value = AddNode(&project); + value->SetStandardValue(olive::ValueNode::kValueInput, 3.5); + + const olive::NodeValueTable table = GenerateTableAt(value, olive::core::rational(0)); + const olive::NodeValue result = table.Get(olive::NodeValue::kFloat); + ASSERT_EQ(result.type(), olive::NodeValue::kFloat); + EXPECT_DOUBLE_EQ(result.toDouble(), 3.5); +} + +TEST(ValueNode, ValuePassesThroughVectorAfterTypeSwitch) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::ValueNode *value = AddNode(&project); + + // Switch the value input to kVec3 (index 4) and set a vector value + value->SetStandardValue(olive::ValueNode::kTypeInput, 4); + value->SetStandardValue(olive::ValueNode::kValueInput, + QVector3D(1.0f, 2.0f, 3.0f)); + + const olive::NodeValueTable table = GenerateTableAt(value, olive::core::rational(0)); + const olive::NodeValue result = table.Get(olive::NodeValue::kVec3); + ASSERT_EQ(result.type(), olive::NodeValue::kVec3); + const QVector3D vec = result.toVec3(); + EXPECT_FLOAT_EQ(vec.x(), 1.0f); + EXPECT_FLOAT_EQ(vec.y(), 2.0f); + EXPECT_FLOAT_EQ(vec.z(), 3.0f); +} diff --git a/tests/gtest/node_distort_test.cpp b/tests/gtest/node_distort_test.cpp new file mode 100644 index 000000000..2d3553d1f --- /dev/null +++ b/tests/gtest/node_distort_test.cpp @@ -0,0 +1,1779 @@ +#include + +#include +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/distort/cornerpin/cornerpindistortnode.h" +#include "node/distort/crop/cropdistortnode.h" +#include "node/distort/flip/flipdistortnode.h" +#include "node/distort/mask/mask.h" +#include "node/distort/ripple/rippledistortnode.h" +#include "node/distort/swirl/swirldistortnode.h" +#include "node/distort/tile/tiledistortnode.h" +#include "node/distort/transform/transformdistortnode.h" +#include "node/distort/wave/wavedistortnode.h" +#include "node/filter/blur/blur.h" +#include "node/generator/polygon/polygon.h" +#include "node/generator/shape/generatorwithmerge.h" +#include "node/globals.h" +#include "node/project.h" +#include "node/traverser.h" +#include "olive/core/util/color.h" +#include "render/job/generatejob.h" +#include "render/job/shaderjob.h" +#include "render/loopmode.h" +#include "render/texture.h" +#include "render/videoparams.h" +#include "widget/slider/floatslider.h" + +namespace +{ + +// Node that pushes a fixed dummy texture, used to feed the texture input of +// distort nodes without any renderer (same pattern as node_generator_test). +class ConstantTextureNode : public olive::Node { +public: + ConstantTextureNode() = default; + + NODE_DEFAULT_FUNCTIONS(ConstantTextureNode) + + virtual QString Name() const override + { + return QStringLiteral("Test Texture"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.distort_texture"); + } + + virtual QVector Category() const override + { + return { kCategoryGenerator }; + } + + void SetTexture(const olive::TexturePtr &texture) + { + texture_ = texture; + } + + virtual void Value(const olive::NodeValueRow &value, + const olive::NodeGlobals &globals, + olive::NodeValueTable *table) const override + { + Q_UNUSED(value) + Q_UNUSED(globals) + + table->Push(olive::NodeValue(olive::NodeValue::kTexture, texture_, this)); + } + +private: + olive::TexturePtr texture_; +}; + +template T *AddNode(olive::Project *project) +{ + T *node = new T(); + node->setParent(project); + return node; +} + +olive::TimeRange FirstFrame() +{ + return olive::TimeRange(olive::rational(0), olive::rational(1, 30)); +} + +// A fresh traverser per call: NodeTraverser caches tables per node/range, so +// reusing one would return stale results after changing standard values. +olive::NodeValueTable GenerateTable(const olive::Node *node, + const olive::VideoParams &vparams) +{ + olive::NodeTraverser traverser; + traverser.SetCacheVideoParams(vparams); + return traverser.GenerateTable(node, FirstFrame()); +} + +// A "dummy" texture has no renderer backend and is therefore safe to pass +// around in a headless, CPU-only test. +olive::TexturePtr MakeDummyTexture(int width, int height) +{ + return std::make_shared( + olive::VideoParams(width, height, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount)); +} + +olive::VideoParams SequenceParams(int width, int height) +{ + return olive::VideoParams(width, height, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount); +} + +olive::NodeValue TextureValue(const olive::TexturePtr &texture) +{ + return olive::NodeValue(olive::NodeValue::kTexture, texture); +} + +olive::NodeValue FloatValue(double v) +{ + return olive::NodeValue(olive::NodeValue::kFloat, v); +} + +olive::NodeValue BoolValue(bool b) +{ + return olive::NodeValue(olive::NodeValue::kBoolean, b); +} + +olive::NodeValue Vec2Value(const QVector2D &v) +{ + return olive::NodeValue(olive::NodeValue::kVec2, v); +} + +olive::NodeValueRow MakeTextureRow(const QString &input, + const olive::TexturePtr &tex) +{ + olive::NodeValueRow row; + row.insert(input, TextureValue(tex)); + return row; +} + +olive::TexturePtr GetOutputTexture(const olive::NodeValueTable &table) +{ + return table.Get(olive::NodeValue::kTexture).toTexture(); +} + +} // namespace + +// ----------------------------------------------------------------------------- +// TransformDistortNode +// ----------------------------------------------------------------------------- + +TEST(TransformDistortNode, MetadataIsCorrect) +{ + olive::TransformDistortNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.transform")); + EXPECT_EQ(node.Name(), QStringLiteral("Transform")); + // ShortName() overrides MatrixGenerator's "Ortho" + EXPECT_EQ(node.ShortName(), QStringLiteral("Transform")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::TransformDistortNode::kTextureInput); +} + +TEST(TransformDistortNode, InputDefinitionsAndDefaults) +{ + olive::TransformDistortNode node; + + // Texture is prepended, so it is the primary effect input and cannot be + // keyframed + ASSERT_TRUE(node.HasInputWithID(olive::TransformDistortNode::kTextureInput)); + EXPECT_EQ(int(node.GetInputDataType(olive::TransformDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::TransformDistortNode::kTextureInput)); + + ASSERT_TRUE(node.HasInputWithID(olive::TransformDistortNode::kParentInput)); + EXPECT_EQ(int(node.GetInputDataType(olive::TransformDistortNode::kParentInput)), + int(olive::NodeValue::kMatrix)); + + ASSERT_TRUE(node.HasInputWithID(olive::TransformDistortNode::kAutoscaleInput)); + EXPECT_EQ( + int(node.GetInputDataType(olive::TransformDistortNode::kAutoscaleInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node.GetStandardValue(olive::TransformDistortNode::kAutoscaleInput) + .toInt(), + int(olive::TransformDistortNode::kAutoScaleNone)); + + ASSERT_TRUE( + node.HasInputWithID(olive::TransformDistortNode::kInterpolationInput)); + EXPECT_EQ(int(node.GetInputDataType( + olive::TransformDistortNode::kInterpolationInput)), + int(olive::NodeValue::kCombo)); + // 2 = mipmapped bilinear + EXPECT_EQ(node.GetStandardValue( + olive::TransformDistortNode::kInterpolationInput) + .toInt(), + int(olive::Texture::kMipmappedLinear)); + + // MatrixGenerator inputs are inherited + EXPECT_TRUE(node.HasInputWithID(olive::MatrixGenerator::kPositionInput)); + EXPECT_TRUE(node.HasInputWithID(olive::MatrixGenerator::kRotationInput)); + EXPECT_TRUE(node.HasInputWithID(olive::MatrixGenerator::kScaleInput)); + EXPECT_TRUE(node.HasInputWithID(olive::MatrixGenerator::kUniformScaleInput)); + EXPECT_TRUE(node.HasInputWithID(olive::MatrixGenerator::kAnchorInput)); +} + +TEST(TransformDistortNode, RetranslateSetsNamesAndComboStrings) +{ + olive::TransformDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::TransformDistortNode::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::TransformDistortNode::kParentInput), + QStringLiteral("Parent")); + EXPECT_EQ(node.GetInputName(olive::TransformDistortNode::kAutoscaleInput), + QStringLiteral("Auto-Scale")); + EXPECT_EQ(node.GetInputName(olive::TransformDistortNode::kInterpolationInput), + QStringLiteral("Interpolation")); + + // Inherited names from MatrixGenerator + EXPECT_EQ(node.GetInputName(olive::MatrixGenerator::kPositionInput), + QStringLiteral("Position")); + EXPECT_EQ(node.GetInputName(olive::MatrixGenerator::kAnchorInput), + QStringLiteral("Anchor Point")); + + const QStringList autoscale = node.GetComboBoxStrings( + olive::TransformDistortNode::kAutoscaleInput); + ASSERT_EQ(autoscale.size(), 4); + EXPECT_EQ(autoscale.at(int(olive::TransformDistortNode::kAutoScaleNone)), + QStringLiteral("None")); + EXPECT_EQ(autoscale.at(int(olive::TransformDistortNode::kAutoScaleFit)), + QStringLiteral("Fit")); + EXPECT_EQ(autoscale.at(int(olive::TransformDistortNode::kAutoScaleFill)), + QStringLiteral("Fill")); + EXPECT_EQ(autoscale.at(int(olive::TransformDistortNode::kAutoScaleStretch)), + QStringLiteral("Stretch")); + + const QStringList interpolation = node.GetComboBoxStrings( + olive::TransformDistortNode::kInterpolationInput); + ASSERT_EQ(interpolation.size(), 3); + EXPECT_EQ(interpolation.at(int(olive::Texture::kNearest)), + QStringLiteral("Nearest Neighbor")); + EXPECT_EQ(interpolation.at(int(olive::Texture::kLinear)), + QStringLiteral("Bilinear")); + EXPECT_EQ(interpolation.at(int(olive::Texture::kMipmappedLinear)), + QStringLiteral("Mipmapped Bilinear")); +} + +TEST(TransformDistortNode, GetShaderCodeReturnsEmptyCode) +{ + olive::TransformDistortNode node; + + // The transform is applied through the ove_mvpmat uniform of the default + // shader, so the node provides no shader code of its own + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_TRUE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.vert_code().isEmpty()); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsIdentityWhenMatching) +{ + // With identical sequence and texture resolutions, no offset and an + // identity input matrix, the adjusted matrix must remain identity (the + // scale to clip space and back cancels out) + const QMatrix4x4 adjusted = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 1024.0f), QVector2D(1024.0f, 1024.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleNone); + EXPECT_TRUE(adjusted.isIdentity()); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsAppliesOffset) +{ + // The offset lives in texture pixel space: with matching 1024x1024 + // resolutions, offsetting by (128, 256) maps the origin to clip space + // (128*2/1024, 256*2/1024) = (0.25, 0.5) + const QMatrix4x4 adjusted = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 1024.0f), QVector2D(1024.0f, 1024.0f), + QVector2D(128.0f, 256.0f), olive::TransformDistortNode::kAutoScaleNone); + + const QVector3D mapped = adjusted.map(QVector3D(0.0f, 0.0f, 0.0f)); + EXPECT_FLOAT_EQ(mapped.x(), 0.25f); + EXPECT_FLOAT_EQ(mapped.y(), 0.5f); + EXPECT_FLOAT_EQ(mapped.z(), 0.0f); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsScalesToClipSpace) +{ + // Without auto-scale a 512x256 texture in a 1024x512 sequence covers only + // half the frame in each axis + const QMatrix4x4 adjusted = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 512.0f), QVector2D(512.0f, 256.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleNone); + + const QVector3D corner = adjusted.map(QVector3D(1.0f, 1.0f, 0.0f)); + EXPECT_FLOAT_EQ(corner.x(), 0.5f); + EXPECT_FLOAT_EQ(corner.y(), 0.5f); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsStretchFillsSequence) +{ + // Stretch distorts the texture to the sequence aspect ratio exactly + const QMatrix4x4 adjusted = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 512.0f), QVector2D(512.0f, 256.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleStretch); + + const QVector3D corner = adjusted.map(QVector3D(1.0f, 1.0f, 0.0f)); + EXPECT_FLOAT_EQ(corner.x(), 1.0f); + EXPECT_FLOAT_EQ(corner.y(), 1.0f); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsFitWideFootage) +{ + // Footage wider than the sequence (AR 4.0 in AR 2.0) is scaled by width, + // leaving letterbox bars: the vertical clip extent shrinks to 0.5 + const QMatrix4x4 fit = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 512.0f), QVector2D(1024.0f, 256.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleFit); + + const QVector3D corner = fit.map(QVector3D(1.0f, 1.0f, 0.0f)); + EXPECT_FLOAT_EQ(corner.x(), 1.0f); + EXPECT_FLOAT_EQ(corner.y(), 0.5f); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsFillWideFootage) +{ + // Fill scales the same footage by height instead, cropping the sides + const QMatrix4x4 fill = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 512.0f), QVector2D(1024.0f, 256.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleFill); + + const QVector3D corner = fill.map(QVector3D(1.0f, 1.0f, 0.0f)); + EXPECT_FLOAT_EQ(corner.x(), 2.0f); + EXPECT_FLOAT_EQ(corner.y(), 1.0f); +} + +TEST(TransformDistortNode, AdjustMatrixByResolutionsFitTallFootage) +{ + // Footage narrower than the sequence (AR 0.5 in AR 2.0) is scaled by + // height, leaving pillarbox bars + const QMatrix4x4 fit = olive::TransformDistortNode::AdjustMatrixByResolutions( + QMatrix4x4(), QVector2D(1024.0f, 512.0f), QVector2D(256.0f, 512.0f), + QVector2D(0.0f, 0.0f), olive::TransformDistortNode::kAutoScaleFit); + + const QVector3D corner = fit.map(QVector3D(1.0f, 1.0f, 0.0f)); + EXPECT_FLOAT_EQ(corner.x(), 0.25f); + EXPECT_FLOAT_EQ(corner.y(), 1.0f); +} + +TEST(TransformDistortNode, ValueWithoutTexturePushesMatrixOnly) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(1920, 1080)); + + // The generated matrix is always pushed; with no texture connected the + // re-pushed texture value is a null texture + const olive::NodeValue matrix = table.Get(olive::NodeValue::kMatrix); + ASSERT_EQ(int(matrix.type()), int(olive::NodeValue::kMatrix)); + EXPECT_TRUE(matrix.toMatrix().isIdentity()); + + EXPECT_TRUE(table.Get(olive::NodeValue::kTexture).toTexture() == nullptr); +} + +TEST(TransformDistortNode, ValueWithIdentityTransformPassesTextureThrough) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + auto *constant = AddNode(&project); + + // Texture matching the sequence resolution with default transform values + // produces an identity adjusted matrix, which the node treats as a no-op + const olive::TexturePtr base = MakeDummyTexture(1024, 1024); + constant->SetTexture(base); + olive::Node::ConnectEdge( + constant, + olive::NodeInput(node, olive::TransformDistortNode::kTextureInput)); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(1024, 1024)); + + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, base); + EXPECT_FALSE(out->IsJob()); + + EXPECT_TRUE(table.Get(olive::NodeValue::kMatrix).toMatrix().isIdentity()); +} + +TEST(TransformDistortNode, ValueWithTexturePushesMatrixJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + auto *constant = AddNode(&project); + + // A 64x48 texture in a 1920x1080 sequence yields a non-identity matrix + const olive::TexturePtr base = MakeDummyTexture(64, 48); + constant->SetTexture(base); + olive::Node::ConnectEdge( + constant, + olive::NodeInput(node, olive::TransformDistortNode::kTextureInput)); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(1920, 1080)); + + olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + // The job adopts the sequence resolution, not the texture's, since the + // transform may change the apparent size + EXPECT_EQ(out->params().width(), 1920); + EXPECT_EQ(out->params().height(), 1080); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + + // The original texture is fed in as ove_maintex + EXPECT_EQ(job->Get(QStringLiteral("ove_maintex")).toTexture(), base); + + // The mvp matrix scales the texture into sequence clip space: + // 64/1920 on X and 48/1080 on Y + const QMatrix4x4 mvp = job->Get(QStringLiteral("ove_mvpmat")).toMatrix(); + EXPECT_NEAR(mvp(0, 0), 64.0 / 1920.0, 1e-6); + EXPECT_NEAR(mvp(1, 1), 48.0 / 1080.0, 1e-6); + EXPECT_FLOAT_EQ(mvp(2, 2), 1.0f); + EXPECT_FLOAT_EQ(mvp(3, 3), 1.0f); + + // The raw generated matrix (identity here) is pushed alongside the job + EXPECT_TRUE(table.Get(olive::NodeValue::kMatrix).toMatrix().isIdentity()); + + // Interpolation defaults to mipmapped bilinear and follows the input + EXPECT_EQ(int(job->GetInterpolation(QStringLiteral("ove_maintex"))), + int(olive::Texture::kMipmappedLinear)); + + node->SetStandardValue(olive::TransformDistortNode::kInterpolationInput, + int(olive::Texture::kNearest)); + table = GenerateTable(node, SequenceParams(1920, 1080)); + out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_EQ(int(job->GetInterpolation(QStringLiteral("ove_maintex"))), + int(olive::Texture::kNearest)); +} + +TEST(TransformDistortNode, ValueBakesPositionIntoJobMatrix) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kPositionInput, + QVector2D(100.0f, 50.0f)); + + auto *constant = AddNode(&project); + constant->SetTexture(MakeDummyTexture(64, 48)); + olive::Node::ConnectEdge( + constant, + olive::NodeInput(node, olive::TransformDistortNode::kTextureInput)); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(1920, 1080)); + + // The table matrix is the pure transform: a 100x50 pixel translation + const QMatrix4x4 generated = table.Get(olive::NodeValue::kMatrix).toMatrix(); + const QVector3D raw = generated.map(QVector3D(0.0f, 0.0f, 0.0f)); + EXPECT_FLOAT_EQ(raw.x(), 100.0f); + EXPECT_FLOAT_EQ(raw.y(), 50.0f); + + // The job matrix expresses the same translation in clip space + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + + const QVector3D clip = job->Get(QStringLiteral("ove_mvpmat")) + .toMatrix() + .map(QVector3D(0.0f, 0.0f, 0.0f)); + EXPECT_NEAR(clip.x(), 100.0 * 2.0 / 1920.0, 1e-6); + EXPECT_NEAR(clip.y(), 50.0 * 2.0 / 1080.0, 1e-6); +} + +// ----------------------------------------------------------------------------- +// CropDistortNode +// ----------------------------------------------------------------------------- + +TEST(CropDistortNode, MetadataIsCorrect) +{ + olive::CropDistortNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.crop")); + EXPECT_EQ(node.Name(), QStringLiteral("Crop")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::CropDistortNode::kTextureInput); +} + +TEST(CropDistortNode, InputDefinitionsAndDefaults) +{ + olive::CropDistortNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::CropDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::CropDistortNode::kTextureInput)); + + // All four sides are 0..1 percentage sliders defaulting to zero + const QString sides[] = { olive::CropDistortNode::kLeftInput, + olive::CropDistortNode::kTopInput, + olive::CropDistortNode::kRightInput, + olive::CropDistortNode::kBottomInput }; + for (const QString &side : sides) { + EXPECT_EQ(int(node.GetInputDataType(side)), int(olive::NodeValue::kFloat)); + EXPECT_DOUBLE_EQ(node.GetStandardValue(side).toDouble(), 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(side, QStringLiteral("min")).toDouble(), 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(side, QStringLiteral("max")).toDouble(), 1.0); + EXPECT_EQ(node.GetInputProperty(side, QStringLiteral("view")).toInt(), + int(olive::FloatSlider::kPercentage)); + } + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::CropDistortNode::kFeatherInput).toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::CropDistortNode::kFeatherInput, + QStringLiteral("min")) + .toDouble(), + 0.0); +} + +TEST(CropDistortNode, RetranslateSetsInputNames) +{ + olive::CropDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kLeftInput), + QStringLiteral("Left")); + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kTopInput), + QStringLiteral("Top")); + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kRightInput), + QStringLiteral("Right")); + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kBottomInput), + QStringLiteral("Bottom")); + EXPECT_EQ(node.GetInputName(olive::CropDistortNode::kFeatherInput), + QStringLiteral("Feather")); +} + +TEST(CropDistortNode, GetShaderCodeLoadsCropShader) +{ + olive::CropDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("left_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("feather_in"))); + EXPECT_TRUE(code.vert_code().isEmpty()); +} + +TEST(CropDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::CropDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(CropDistortNode, ValueWithZeroCropPassesTextureThrough) +{ + olive::CropDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(120, 80); + olive::NodeValueTable table; + node.Value(MakeTextureRow(olive::CropDistortNode::kTextureInput, tex), + olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(CropDistortNode, ValueWithOnlyFeatherPassesTextureThrough) +{ + olive::CropDistortNode node; + + // NOTE: the node only checks the four crop sides when deciding to run the + // shader; a feather without any crop is silently ignored + const olive::TexturePtr tex = MakeDummyTexture(120, 80); + olive::NodeValueRow row = + MakeTextureRow(olive::CropDistortNode::kTextureInput, tex); + row.insert(olive::CropDistortNode::kFeatherInput, FloatValue(5.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(CropDistortNode, ValueWithCropPushesShaderJob) +{ + olive::CropDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(120, 80); + olive::NodeValueRow row = + MakeTextureRow(olive::CropDistortNode::kTextureInput, tex); + row.insert(olive::CropDistortNode::kLeftInput, FloatValue(0.25)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + // The job reuses the input texture's params + EXPECT_EQ(out->params().width(), tex->params().width()); + EXPECT_EQ(out->params().height(), tex->params().height()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_DOUBLE_EQ(job->Get(olive::CropDistortNode::kLeftInput).toDouble(), + 0.25); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + QVector2D(120.0f, 80.0f)); +} + +// ----------------------------------------------------------------------------- +// FlipDistortNode +// ----------------------------------------------------------------------------- + +TEST(FlipDistortNode, MetadataIsCorrect) +{ + olive::FlipDistortNode node; + // NOTE: unlike most Olive nodes ("org.olivevideoeditor.Olive.*"), the + // flip/ripple/swirl/tile/wave nodes use the "org.oliveeditor.Olive.*" + // domain (inconsistent ID, documented here as a suspected bug) + EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.flip")); + EXPECT_EQ(node.Name(), QStringLiteral("Flip")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::FlipDistortNode::kTextureInput); +} + +TEST(FlipDistortNode, InputDefaults) +{ + olive::FlipDistortNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::FlipDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::FlipDistortNode::kTextureInput)); + + EXPECT_EQ( + int(node.GetInputDataType(olive::FlipDistortNode::kHorizontalInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node.GetStandardValue(olive::FlipDistortNode::kHorizontalInput) + .toBool()); + EXPECT_EQ(int(node.GetInputDataType(olive::FlipDistortNode::kVerticalInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node.GetStandardValue(olive::FlipDistortNode::kVerticalInput) + .toBool()); +} + +TEST(FlipDistortNode, RetranslateSetsInputNames) +{ + olive::FlipDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::FlipDistortNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::FlipDistortNode::kHorizontalInput), + QStringLiteral("Horizontal")); + EXPECT_EQ(node.GetInputName(olive::FlipDistortNode::kVerticalInput), + QStringLiteral("Vertical")); +} + +TEST(FlipDistortNode, GetShaderCodeLoadsFlipShader) +{ + olive::FlipDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("horiz_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("vert_in"))); +} + +TEST(FlipDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::FlipDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(FlipDistortNode, ValueWithNoFlipPassesTextureThrough) +{ + olive::FlipDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueTable table; + node.Value(MakeTextureRow(olive::FlipDistortNode::kTextureInput, tex), + olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(FlipDistortNode, ValueWithFlipPushesShaderJob) +{ + olive::FlipDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::FlipDistortNode::kTextureInput, tex); + row.insert(olive::FlipDistortNode::kHorizontalInput, BoolValue(true)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + EXPECT_EQ(out->params().width(), tex->params().width()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_TRUE(job->Get(olive::FlipDistortNode::kHorizontalInput).toBool()); + EXPECT_FALSE(job->Get(olive::FlipDistortNode::kVerticalInput).toBool()); + + // Vertical flip on its own also triggers the shader + row.insert(olive::FlipDistortNode::kHorizontalInput, BoolValue(false)); + row.insert(olive::FlipDistortNode::kVerticalInput, BoolValue(true)); + + olive::NodeValueTable vertical_table; + node.Value(row, olive::NodeGlobals(), &vertical_table); + + ASSERT_EQ(vertical_table.Count(), 1); + out = GetOutputTexture(vertical_table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_FALSE(job->Get(olive::FlipDistortNode::kHorizontalInput).toBool()); + EXPECT_TRUE(job->Get(olive::FlipDistortNode::kVerticalInput).toBool()); +} + +// ----------------------------------------------------------------------------- +// CornerPinDistortNode +// ----------------------------------------------------------------------------- + +TEST(CornerPinDistortNode, MetadataIsCorrect) +{ + olive::CornerPinDistortNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.cornerpin")); + EXPECT_EQ(node.Name(), QStringLiteral("Corner Pin")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::CornerPinDistortNode::kTextureInput); +} + +TEST(CornerPinDistortNode, InputDefaults) +{ + olive::CornerPinDistortNode node; + + EXPECT_EQ( + int(node.GetInputDataType(olive::CornerPinDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::CornerPinDistortNode::kTextureInput)); + + EXPECT_EQ(int(node.GetInputDataType( + olive::CornerPinDistortNode::kPerspectiveInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_TRUE(node.GetStandardValue( + olive::CornerPinDistortNode::kPerspectiveInput) + .toBool()); + + // All four corners are pixel offsets relative to their respective image + // corner and default to no offset + const QString corners[] = { olive::CornerPinDistortNode::kTopLeftInput, + olive::CornerPinDistortNode::kTopRightInput, + olive::CornerPinDistortNode::kBottomRightInput, + olive::CornerPinDistortNode::kBottomLeftInput }; + for (const QString &corner : corners) { + EXPECT_EQ(int(node.GetInputDataType(corner)), + int(olive::NodeValue::kVec2)); + EXPECT_EQ(node.GetStandardValue(corner).value(), + QVector2D(0.0f, 0.0f)); + } +} + +TEST(CornerPinDistortNode, RetranslateSetsInputNames) +{ + olive::CornerPinDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kPerspectiveInput), + QStringLiteral("Perspective")); + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kTopLeftInput), + QStringLiteral("Top Left")); + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kTopRightInput), + QStringLiteral("Top Right")); + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kBottomRightInput), + QStringLiteral("Bottom Right")); + EXPECT_EQ(node.GetInputName(olive::CornerPinDistortNode::kBottomLeftInput), + QStringLiteral("Bottom Left")); +} + +TEST(CornerPinDistortNode, GetShaderCodeLoadsFragAndVertShaders) +{ + olive::CornerPinDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("perspective_in"))); + // Corner Pin is one of the few nodes with a custom vertex shader + EXPECT_FALSE(code.vert_code().isEmpty()); + EXPECT_TRUE(code.vert_code().contains(QStringLiteral("top_left_in"))); +} + +TEST(CornerPinDistortNode, ValueToPixelConvertsOffsetsToPixels) +{ + olive::CornerPinDistortNode node; + + olive::NodeValueRow row; + row.insert(olive::CornerPinDistortNode::kTopLeftInput, + Vec2Value(QVector2D(10.0f, 20.0f))); + row.insert(olive::CornerPinDistortNode::kTopRightInput, + Vec2Value(QVector2D(-30.0f, 40.0f))); + row.insert(olive::CornerPinDistortNode::kBottomRightInput, + Vec2Value(QVector2D(-50.0f, -60.0f))); + row.insert(olive::CornerPinDistortNode::kBottomLeftInput, + Vec2Value(QVector2D(70.0f, -80.0f))); + + const QVector2D resolution(200.0f, 100.0f); + + // Top-left offsets are relative to (0, 0) + const QPointF top_left = node.ValueToPixel(0, row, resolution); + EXPECT_DOUBLE_EQ(top_left.x(), 10.0); + EXPECT_DOUBLE_EQ(top_left.y(), 20.0); + + // Top-right offsets are relative to (width, 0) + const QPointF top_right = node.ValueToPixel(1, row, resolution); + EXPECT_DOUBLE_EQ(top_right.x(), 170.0); + EXPECT_DOUBLE_EQ(top_right.y(), 40.0); + + // Bottom-right offsets are relative to (width, height) + const QPointF bottom_right = node.ValueToPixel(2, row, resolution); + EXPECT_DOUBLE_EQ(bottom_right.x(), 150.0); + EXPECT_DOUBLE_EQ(bottom_right.y(), 40.0); + + // Bottom-left offsets are relative to (0, height) + const QPointF bottom_left = node.ValueToPixel(3, row, resolution); + EXPECT_DOUBLE_EQ(bottom_left.x(), 70.0); + EXPECT_DOUBLE_EQ(bottom_left.y(), 20.0); +} + +TEST(CornerPinDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::CornerPinDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(CornerPinDistortNode, ValueWithDefaultCornersPassesTextureThrough) +{ + olive::CornerPinDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(100, 100); + olive::NodeValueTable table; + node.Value(MakeTextureRow(olive::CornerPinDistortNode::kTextureInput, tex), + olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(CornerPinDistortNode, ValueWithMovedCornerPushesVertexCoordinates) +{ + olive::CornerPinDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(100, 100); + olive::NodeValueRow row = + MakeTextureRow(olive::CornerPinDistortNode::kTextureInput, tex); + row.insert(olive::CornerPinDistortNode::kTopLeftInput, + Vec2Value(QVector2D(10.0f, 20.0f))); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + EXPECT_EQ(out->params().width(), tex->params().width()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + QVector2D(100.0f, 100.0f)); + + // Slider offsets are converted to pixel positions and then to clip space + // (-1..1): top-left (10, 20) -> (-0.8, -0.6), the untouched corners land + // on the default quad + const QVector &vertices = job->GetVertexCoordinates(); + ASSERT_EQ(int(vertices.size()), 18); + + // First triangle + EXPECT_FLOAT_EQ(vertices.at(0), -0.8f); + EXPECT_FLOAT_EQ(vertices.at(1), -0.6f); + EXPECT_FLOAT_EQ(vertices.at(2), 0.0f); + EXPECT_FLOAT_EQ(vertices.at(3), 1.0f); + EXPECT_FLOAT_EQ(vertices.at(4), -1.0f); + EXPECT_FLOAT_EQ(vertices.at(5), 0.0f); + EXPECT_FLOAT_EQ(vertices.at(6), 1.0f); + EXPECT_FLOAT_EQ(vertices.at(7), 1.0f); + EXPECT_FLOAT_EQ(vertices.at(8), 0.0f); + + // Second triangle + EXPECT_FLOAT_EQ(vertices.at(9), -0.8f); + EXPECT_FLOAT_EQ(vertices.at(10), -0.6f); + EXPECT_FLOAT_EQ(vertices.at(12), -1.0f); + EXPECT_FLOAT_EQ(vertices.at(13), 1.0f); + EXPECT_FLOAT_EQ(vertices.at(15), 1.0f); + EXPECT_FLOAT_EQ(vertices.at(16), 1.0f); +} + +// ----------------------------------------------------------------------------- +// MaskDistortNode +// ----------------------------------------------------------------------------- + +TEST(MaskDistortNode, MetadataIsCorrect) +{ + olive::MaskDistortNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.mask")); + EXPECT_EQ(node.Name(), QStringLiteral("Mask")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + // From GeneratorWithMerge: the base texture is the effect input + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::GeneratorWithMerge::kBaseInput); +} + +TEST(MaskDistortNode, InputDefinitionsAndDefaults) +{ + olive::MaskDistortNode node; + + EXPECT_EQ( + int(node.GetInputDataType(olive::MaskDistortNode::kInvertInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node.GetStandardValue(olive::MaskDistortNode::kInvertInput) + .toBool()); + + EXPECT_EQ( + int(node.GetInputDataType(olive::MaskDistortNode::kFeatherInput)), + int(olive::NodeValue::kFloat)); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MaskDistortNode::kFeatherInput).toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::MaskDistortNode::kFeatherInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + + // From PolygonGenerator: the color is hidden because the mask must stay + // white for the multiply to work, and the shape defaults to a pentagon + EXPECT_TRUE(node.IsInputHidden(olive::PolygonGenerator::kColorInput)); + EXPECT_TRUE(node.InputIsArray(olive::PolygonGenerator::kPointsInput)); + EXPECT_EQ(node.InputArraySize(olive::PolygonGenerator::kPointsInput), 5); +} + +TEST(MaskDistortNode, RetranslateSetsInputNames) +{ + olive::MaskDistortNode node; + node.Retranslate(); + + // The base input is renamed from GeneratorWithMerge's "Base" + EXPECT_EQ(node.GetInputName(olive::GeneratorWithMerge::kBaseInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::MaskDistortNode::kInvertInput), + QStringLiteral("Invert")); + EXPECT_EQ(node.GetInputName(olive::MaskDistortNode::kFeatherInput), + QStringLiteral("Feather")); + + // Inherited names from PolygonGenerator + EXPECT_EQ(node.GetInputName(olive::PolygonGenerator::kPointsInput), + QStringLiteral("Points")); + EXPECT_EQ(node.GetInputName(olive::PolygonGenerator::kColorInput), + QStringLiteral("Color")); +} + +TEST(MaskDistortNode, GetShaderCodeSelectsShaderByRequestId) +{ + olive::MaskDistortNode node; + + const olive::ShaderCode merge = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("mrg"))); + EXPECT_FALSE(merge.frag_code().isEmpty()); + EXPECT_TRUE(merge.frag_code().contains(QStringLiteral("tex_a"))); + + const olive::ShaderCode feather = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("feather"))); + EXPECT_FALSE(feather.frag_code().isEmpty()); + EXPECT_TRUE(feather.frag_code().contains(QStringLiteral("radius_in"))); + + const olive::ShaderCode invert = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("invert"))); + EXPECT_FALSE(invert.frag_code().isEmpty()); + EXPECT_TRUE(invert.frag_code().contains(QStringLiteral("tex_in"))); + + // Unknown ids fall through to PolygonGenerator, which serves "rgb" + const olive::ShaderCode rgb = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("rgb"))); + EXPECT_FALSE(rgb.frag_code().isEmpty()); + EXPECT_TRUE(rgb.frag_code().contains(QStringLiteral("texture_in"))); + + const olive::ShaderCode unknown = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("bogus"))); + EXPECT_TRUE(unknown.frag_code().isEmpty()); + EXPECT_TRUE(unknown.vert_code().isEmpty()); +} + +TEST(MaskDistortNode, ValueWithoutTexturePushesGeneratePipeline) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams = SequenceParams(320, 240); + olive::NodeValueTable table = GenerateTable(node, vparams); + + // Without a base the mask still generates its polygon, wrapped in the + // "rgb" conversion job + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + EXPECT_EQ(out->params().width(), vparams.width()); + EXPECT_EQ(out->params().height(), vparams.height()); + + auto *rgb = dynamic_cast(out->job()); + ASSERT_TRUE(rgb); + EXPECT_EQ(rgb->GetShaderID(), QStringLiteral("rgb")); + + // The polygon color is forced to white + const olive::core::Color color = + rgb->Get(QStringLiteral("color_in")).toColor(); + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_FLOAT_EQ(color.green(), 1.0f); + EXPECT_FLOAT_EQ(color.blue(), 1.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); + + // The nested generation job renders to an 8-bit buffer + const olive::TexturePtr generate = + rgb->Get(QStringLiteral("texture_in")).toTexture(); + ASSERT_TRUE(generate); + ASSERT_TRUE(generate->IsJob()); + EXPECT_EQ(int(generate->params().format()), int(olive::core::PixelFormat::U8)); + EXPECT_TRUE(dynamic_cast(generate->job())); +} + +TEST(MaskDistortNode, ValueWithInvertWrapsGenerationInInvertJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MaskDistortNode::kInvertInput, true); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(320, 240)); + + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *invert = dynamic_cast(out->job()); + ASSERT_TRUE(invert); + EXPECT_EQ(invert->GetShaderID(), QStringLiteral("invert")); + + // The inverted texture is the usual rgb generation pipeline + const olive::TexturePtr rgb_tex = + invert->Get(QStringLiteral("tex_in")).toTexture(); + ASSERT_TRUE(rgb_tex); + ASSERT_TRUE(rgb_tex->IsJob()); + auto *rgb = dynamic_cast(rgb_tex->job()); + ASSERT_TRUE(rgb); + EXPECT_EQ(rgb->GetShaderID(), QStringLiteral("rgb")); +} + +TEST(MaskDistortNode, ValueWithTexturePushesMergeJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + auto *constant = AddNode(&project); + + const olive::TexturePtr base = MakeDummyTexture(64, 48); + constant->SetTexture(base); + olive::Node::ConnectEdge( + constant, olive::NodeInput(node, olive::GeneratorWithMerge::kBaseInput)); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(320, 240)); + + // With a base the mask multiplies it by the generated polygon + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + EXPECT_EQ(out->params().width(), base->params().width()); + EXPECT_EQ(out->params().height(), base->params().height()); + + auto *merge = dynamic_cast(out->job()); + ASSERT_TRUE(merge); + EXPECT_EQ(merge->GetShaderID(), QStringLiteral("mrg")); + EXPECT_EQ(merge->Get(QStringLiteral("tex_a")).toTexture(), base); + + // Without a feather, tex_b is the rgb generation pipeline directly + const olive::TexturePtr tex_b = + merge->Get(QStringLiteral("tex_b")).toTexture(); + ASSERT_TRUE(tex_b); + ASSERT_TRUE(tex_b->IsJob()); + auto *rgb = dynamic_cast(tex_b->job()); + ASSERT_TRUE(rgb); + EXPECT_EQ(rgb->GetShaderID(), QStringLiteral("rgb")); +} + +TEST(MaskDistortNode, ValueWithFeatherNestsBlurJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MaskDistortNode::kFeatherInput, 10.0); + + auto *constant = AddNode(&project); + const olive::TexturePtr base = MakeDummyTexture(64, 48); + constant->SetTexture(base); + olive::Node::ConnectEdge( + constant, olive::NodeInput(node, olive::GeneratorWithMerge::kBaseInput)); + + olive::NodeValueTable table = GenerateTable(node, SequenceParams(320, 240)); + + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *merge = dynamic_cast(out->job()); + ASSERT_TRUE(merge); + EXPECT_EQ(merge->GetShaderID(), QStringLiteral("mrg")); + + // With a feather, tex_b becomes a two-iteration gaussian blur of the mask + const olive::TexturePtr tex_b = + merge->Get(QStringLiteral("tex_b")).toTexture(); + ASSERT_TRUE(tex_b); + ASSERT_TRUE(tex_b->IsJob()); + + auto *feather = dynamic_cast(tex_b->job()); + ASSERT_TRUE(feather); + EXPECT_EQ(feather->GetShaderID(), QStringLiteral("feather")); + EXPECT_EQ(feather->GetIterationCount(), 2); + EXPECT_EQ(feather->GetIterativeInput(), olive::BlurFilterNode::kTextureInput); + EXPECT_DOUBLE_EQ( + feather->Get(olive::BlurFilterNode::kRadiusInput).toDouble(), 10.0); + EXPECT_EQ(feather->Get(olive::BlurFilterNode::kMethodInput).toInt(), + int(olive::BlurFilterNode::kGaussian)); + EXPECT_EQ(feather->Get(QStringLiteral("resolution_in")).toVec2(), + base->virtual_resolution()); +} + +// ----------------------------------------------------------------------------- +// RippleDistortNode +// ----------------------------------------------------------------------------- + +TEST(RippleDistortNode, MetadataIsCorrect) +{ + olive::RippleDistortNode node; + // NOTE: "org.oliveeditor.*" domain, inconsistent with most Olive nodes + EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.ripple")); + EXPECT_EQ(node.Name(), QStringLiteral("Ripple")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::RippleDistortNode::kTextureInput); +} + +TEST(RippleDistortNode, InputDefaults) +{ + olive::RippleDistortNode node; + + EXPECT_EQ( + int(node.GetInputDataType(olive::RippleDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::RippleDistortNode::kTextureInput)); + + EXPECT_DOUBLE_EQ(node.GetStandardValue(olive::RippleDistortNode::kEvolutionInput) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(node.GetStandardValue(olive::RippleDistortNode::kIntensityInput) + .toDouble(), + 100.0); + EXPECT_DOUBLE_EQ(node.GetStandardValue(olive::RippleDistortNode::kFrequencyInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::RippleDistortNode::kFrequencyInput, + QStringLiteral("base")) + .toDouble(), + 0.01); + EXPECT_EQ(node.GetStandardValue(olive::RippleDistortNode::kPositionInput) + .value(), + QVector2D(0.0f, 0.0f)); + EXPECT_FALSE(node.GetStandardValue(olive::RippleDistortNode::kStretchInput) + .toBool()); +} + +TEST(RippleDistortNode, RetranslateSetsInputNames) +{ + olive::RippleDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kFrequencyInput), + QStringLiteral("Frequency")); + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kIntensityInput), + QStringLiteral("Intensity")); + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kEvolutionInput), + QStringLiteral("Evolution")); + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kPositionInput), + QStringLiteral("Position")); + EXPECT_EQ(node.GetInputName(olive::RippleDistortNode::kStretchInput), + QStringLiteral("Stretch")); +} + +TEST(RippleDistortNode, GetShaderCodeLoadsRippleShader) +{ + olive::RippleDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("evolution_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("intensity_in"))); +} + +TEST(RippleDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::RippleDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(RippleDistortNode, ValueWithZeroIntensityPassesTextureThrough) +{ + olive::RippleDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::RippleDistortNode::kTextureInput, tex); + row.insert(olive::RippleDistortNode::kIntensityInput, FloatValue(0.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(RippleDistortNode, ValueWithIntensityPushesShaderJob) +{ + olive::RippleDistortNode node; + + // With a non-zero intensity the shader runs and receives the texture's + // virtual resolution + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::RippleDistortNode::kTextureInput, tex); + row.insert(olive::RippleDistortNode::kIntensityInput, FloatValue(100.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + EXPECT_EQ(out->params().width(), tex->params().width()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_DOUBLE_EQ(job->Get(olive::RippleDistortNode::kIntensityInput).toDouble(), + 100.0); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + tex->virtual_resolution()); +} + +// ----------------------------------------------------------------------------- +// SwirlDistortNode +// ----------------------------------------------------------------------------- + +TEST(SwirlDistortNode, MetadataIsCorrect) +{ + olive::SwirlDistortNode node; + // NOTE: "org.oliveeditor.*" domain, inconsistent with most Olive nodes + EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.swirl")); + EXPECT_EQ(node.Name(), QStringLiteral("Swirl")); + // NOTE: the description reads "Distorts an image along a sine wave.", + // identical to WaveDistortNode's (copy-paste, documented here) + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::SwirlDistortNode::kTextureInput); +} + +TEST(SwirlDistortNode, InputDefaults) +{ + olive::SwirlDistortNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::SwirlDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::SwirlDistortNode::kTextureInput)); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::SwirlDistortNode::kRadiusInput).toDouble(), + 200.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::SwirlDistortNode::kRadiusInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::SwirlDistortNode::kAngleInput).toDouble(), + 10.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::SwirlDistortNode::kAngleInput, + QStringLiteral("base")) + .toDouble(), + 0.1); + EXPECT_EQ(node.GetStandardValue(olive::SwirlDistortNode::kPositionInput) + .value(), + QVector2D(0.0f, 0.0f)); +} + +TEST(SwirlDistortNode, RetranslateSetsInputNames) +{ + olive::SwirlDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::SwirlDistortNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::SwirlDistortNode::kRadiusInput), + QStringLiteral("Radius")); + EXPECT_EQ(node.GetInputName(olive::SwirlDistortNode::kAngleInput), + QStringLiteral("Angle")); + EXPECT_EQ(node.GetInputName(olive::SwirlDistortNode::kPositionInput), + QStringLiteral("Position")); +} + +TEST(SwirlDistortNode, GetShaderCodeLoadsSwirlShader) +{ + olive::SwirlDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("radius_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("angle_in"))); +} + +TEST(SwirlDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::SwirlDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(SwirlDistortNode, ValueWithZeroAngleOrRadiusPassesTextureThrough) +{ + olive::SwirlDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + + // Zero angle neutralizes the swirl + olive::NodeValueRow row = + MakeTextureRow(olive::SwirlDistortNode::kTextureInput, tex); + row.insert(olive::SwirlDistortNode::kAngleInput, FloatValue(0.0)); + row.insert(olive::SwirlDistortNode::kRadiusInput, FloatValue(200.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + EXPECT_EQ(GetOutputTexture(table), tex); + + // So does a zero radius + row.insert(olive::SwirlDistortNode::kAngleInput, FloatValue(10.0)); + row.insert(olive::SwirlDistortNode::kRadiusInput, FloatValue(0.0)); + + olive::NodeValueTable zero_radius_table; + node.Value(row, olive::NodeGlobals(), &zero_radius_table); + + ASSERT_EQ(zero_radius_table.Count(), 1); + EXPECT_EQ(GetOutputTexture(zero_radius_table), tex); +} + +TEST(SwirlDistortNode, ValueWithAngleAndRadiusPushesShaderJob) +{ + olive::SwirlDistortNode node; + + // With a non-zero angle and radius the shader runs and receives the + // texture's virtual resolution + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::SwirlDistortNode::kTextureInput, tex); + row.insert(olive::SwirlDistortNode::kAngleInput, FloatValue(10.0)); + row.insert(olive::SwirlDistortNode::kRadiusInput, FloatValue(200.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_DOUBLE_EQ(job->Get(olive::SwirlDistortNode::kAngleInput).toDouble(), + 10.0); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + tex->virtual_resolution()); +} + +// ----------------------------------------------------------------------------- +// TileDistortNode +// ----------------------------------------------------------------------------- + +TEST(TileDistortNode, MetadataIsCorrect) +{ + olive::TileDistortNode node; + // NOTE: "org.oliveeditor.*" domain, inconsistent with most Olive nodes + EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.tile")); + EXPECT_EQ(node.Name(), QStringLiteral("Tile")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::TileDistortNode::kTextureInput); +} + +TEST(TileDistortNode, InputDefaults) +{ + olive::TileDistortNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::TileDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::TileDistortNode::kTextureInput)); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::TileDistortNode::kScaleInput).toDouble(), + 0.5); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::TileDistortNode::kScaleInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_EQ(node.GetInputProperty(olive::TileDistortNode::kScaleInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); + + EXPECT_EQ(node.GetStandardValue(olive::TileDistortNode::kPositionInput) + .value(), + QVector2D(0.0f, 0.0f)); + + EXPECT_EQ(int(node.GetInputDataType(olive::TileDistortNode::kAnchorInput)), + int(olive::NodeValue::kCombo)); + // The Anchor enum is private; 4 is kMiddleCenter + EXPECT_EQ(node.GetStandardValue(olive::TileDistortNode::kAnchorInput).toInt(), + 4); + + EXPECT_FALSE(node.GetStandardValue(olive::TileDistortNode::kMirrorXInput) + .toBool()); + EXPECT_FALSE(node.GetStandardValue(olive::TileDistortNode::kMirrorYInput) + .toBool()); +} + +TEST(TileDistortNode, RetranslateSetsNamesAndAnchorComboStrings) +{ + olive::TileDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kScaleInput), + QStringLiteral("Scale")); + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kPositionInput), + QStringLiteral("Position")); + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kAnchorInput), + QStringLiteral("Anchor")); + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kMirrorXInput), + QStringLiteral("Mirror Horizontally")); + EXPECT_EQ(node.GetInputName(olive::TileDistortNode::kMirrorYInput), + QStringLiteral("Mirror Vertically")); + + const QStringList anchors = + node.GetComboBoxStrings(olive::TileDistortNode::kAnchorInput); + ASSERT_EQ(anchors.size(), 9); + EXPECT_EQ(anchors.at(0), QStringLiteral("Top-Left")); + EXPECT_EQ(anchors.at(1), QStringLiteral("Top-Center")); + EXPECT_EQ(anchors.at(2), QStringLiteral("Top-Right")); + EXPECT_EQ(anchors.at(3), QStringLiteral("Middle-Left")); + EXPECT_EQ(anchors.at(4), QStringLiteral("Middle-Center")); + EXPECT_EQ(anchors.at(5), QStringLiteral("Middle-Right")); + EXPECT_EQ(anchors.at(6), QStringLiteral("Bottom-Left")); + EXPECT_EQ(anchors.at(7), QStringLiteral("Bottom-Center")); + EXPECT_EQ(anchors.at(8), QStringLiteral("Bottom-Right")); +} + +TEST(TileDistortNode, GetShaderCodeLoadsTileShader) +{ + olive::TileDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("mirrorx_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("anchor_in"))); +} + +TEST(TileDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::TileDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(TileDistortNode, ValueWithUnitScalePassesTextureThrough) +{ + olive::TileDistortNode node; + + // A scale of exactly 1.0 is a no-op + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::TileDistortNode::kTextureInput, tex); + row.insert(olive::TileDistortNode::kScaleInput, FloatValue(1.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(TileDistortNode, ValueWithNonUnitScalePushesShaderJob) +{ + olive::TileDistortNode node; + + // Any scale other than 1.0 runs the shader + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::TileDistortNode::kTextureInput, tex); + row.insert(olive::TileDistortNode::kScaleInput, FloatValue(0.5)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_DOUBLE_EQ(job->Get(olive::TileDistortNode::kScaleInput).toDouble(), + 0.5); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + tex->virtual_resolution()); +} + +// ----------------------------------------------------------------------------- +// WaveDistortNode +// ----------------------------------------------------------------------------- + +TEST(WaveDistortNode, MetadataIsCorrect) +{ + olive::WaveDistortNode node; + // NOTE: "org.oliveeditor.*" domain, inconsistent with most Olive nodes + EXPECT_EQ(node.id(), QStringLiteral("org.oliveeditor.Olive.wave")); + EXPECT_EQ(node.Name(), QStringLiteral("Wave")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryDistort)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::WaveDistortNode::kTextureInput); +} + +TEST(WaveDistortNode, InputDefaults) +{ + olive::WaveDistortNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::WaveDistortNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::WaveDistortNode::kTextureInput)); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::WaveDistortNode::kFrequencyInput).toDouble(), + 10.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::WaveDistortNode::kIntensityInput).toDouble(), + 10.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::WaveDistortNode::kEvolutionInput).toDouble(), + 0.0); + + EXPECT_EQ(int(node.GetInputDataType(olive::WaveDistortNode::kVerticalInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node.GetStandardValue(olive::WaveDistortNode::kVerticalInput).toInt(), + 0); +} + +TEST(WaveDistortNode, RetranslateSetsNamesAndComboStrings) +{ + olive::WaveDistortNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::WaveDistortNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::WaveDistortNode::kFrequencyInput), + QStringLiteral("Frequency")); + EXPECT_EQ(node.GetInputName(olive::WaveDistortNode::kIntensityInput), + QStringLiteral("Intensity")); + EXPECT_EQ(node.GetInputName(olive::WaveDistortNode::kEvolutionInput), + QStringLiteral("Evolution")); + EXPECT_EQ(node.GetInputName(olive::WaveDistortNode::kVerticalInput), + QStringLiteral("Direction")); + + const QStringList directions = + node.GetComboBoxStrings(olive::WaveDistortNode::kVerticalInput); + ASSERT_EQ(directions.size(), 2); + EXPECT_EQ(directions.at(0), QStringLiteral("Horizontal")); + EXPECT_EQ(directions.at(1), QStringLiteral("Vertical")); +} + +TEST(WaveDistortNode, GetShaderCodeLoadsWaveShader) +{ + olive::WaveDistortNode node; + + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("vertical_in"))); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("intensity_in"))); +} + +TEST(WaveDistortNode, ValueWithoutTexturePushesNothing) +{ + olive::WaveDistortNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(WaveDistortNode, ValueWithZeroIntensityPassesTextureThrough) +{ + olive::WaveDistortNode node; + + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::WaveDistortNode::kTextureInput, tex); + row.insert(olive::WaveDistortNode::kIntensityInput, FloatValue(0.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(WaveDistortNode, ValueWithIntensityPushesShaderJob) +{ + olive::WaveDistortNode node; + + // With a non-zero intensity the shader runs + const olive::TexturePtr tex = MakeDummyTexture(64, 48); + olive::NodeValueRow row = + MakeTextureRow(olive::WaveDistortNode::kTextureInput, tex); + row.insert(olive::WaveDistortNode::kIntensityInput, FloatValue(10.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = GetOutputTexture(table); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + // Unlike the other distorters, WaveDistortNode does not insert a + // resolution_in; the job simply carries the row values with the input + // texture's params + EXPECT_EQ(out->params().width(), tex->params().width()); + EXPECT_EQ(out->params().height(), tex->params().height()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_DOUBLE_EQ(job->Get(olive::WaveDistortNode::kIntensityInput).toDouble(), + 10.0); + EXPECT_TRUE(job->Get(QStringLiteral("resolution_in")).toVec2().isNull()); +} diff --git a/tests/gtest/node_filter_keying_test.cpp b/tests/gtest/node_filter_keying_test.cpp new file mode 100644 index 000000000..bb711d70d --- /dev/null +++ b/tests/gtest/node_filter_keying_test.cpp @@ -0,0 +1,1503 @@ +#include + +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/effect/opacity/opacityeffect.h" +#include "node/filter/blur/blur.h" +#include "node/filter/dropshadow/dropshadowfilter.h" +#include "node/filter/mosaic/mosaicfilternode.h" +#include "node/filter/stroke/stroke.h" +#include "node/keying/chromakey/chromakey.h" +#include "node/keying/colordifferencekey/colordifferencekey.h" +#include "node/keying/despill/despill.h" +#include "node/project.h" +#include "olive/core/util/color.h" +#include "render/job/colortransformjob.h" +#include "render/job/shaderjob.h" +#include "render/texture.h" +#include "widget/slider/floatslider.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::NodeValue TextureValue(const olive::TexturePtr &tex) +{ + return olive::NodeValue(olive::NodeValue::kTexture, tex); +} + +olive::NodeValue FloatValue(double d) +{ + return olive::NodeValue(olive::NodeValue::kFloat, d); +} + +olive::NodeValue BoolValue(bool b) +{ + return olive::NodeValue(olive::NodeValue::kBoolean, b); +} + +olive::NodeValue ComboValue(int i) +{ + return olive::NodeValue(olive::NodeValue::kCombo, i); +} + +olive::NodeValue Vec2Value(const QVector2D &v) +{ + return olive::NodeValue(olive::NodeValue::kVec2, v); +} + +olive::NodeValueRow MakeTextureRow(const QString &input, + const olive::TexturePtr &tex) +{ + olive::NodeValueRow row; + row.insert(input, TextureValue(tex)); + return row; +} + +} // namespace + +// ----------------------------------------------------------------------------- +// OpacityEffect +// ----------------------------------------------------------------------------- + +TEST(OpacityEffect, InputDefinitionsAndDefaults) +{ + olive::OpacityEffect node; + + EXPECT_TRUE(node.HasInputWithID(olive::OpacityEffect::kTextureInput)); + EXPECT_TRUE(node.HasInputWithID(olive::OpacityEffect::kValueInput)); + + EXPECT_EQ(int(node.GetInputDataType(olive::OpacityEffect::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_EQ(int(node.GetInputDataType(olive::OpacityEffect::kValueInput)), + int(olive::NodeValue::kFloat)); + + // The texture input is a static effect input: not keyframable. + EXPECT_FALSE(node.IsInputKeyframable(olive::OpacityEffect::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::OpacityEffect::kTextureInput); + + // Opacity is a 0-100% slider defaulting to fully opaque. + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::OpacityEffect::kValueInput).toDouble(), + 1.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::OpacityEffect::kValueInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::OpacityEffect::kValueInput, + QStringLiteral("max")) + .toDouble(), + 1.0); + EXPECT_EQ(node.GetInputProperty(olive::OpacityEffect::kValueInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); +} + +TEST(OpacityEffect, Identity) +{ + olive::OpacityEffect node; + + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.opacity")); + 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::kCategoryFilter)); +} + +TEST(OpacityEffect, RetranslateSetsInputNames) +{ + olive::OpacityEffect node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::OpacityEffect::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::OpacityEffect::kValueInput), + QStringLiteral("Opacity")); +} + +TEST(OpacityEffect, ShaderCodeSelectsFragmentById) +{ + olive::OpacityEffect node; + + const olive::ShaderCode mult = + node.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("rgbmult"))); + EXPECT_FALSE(mult.frag_code().isEmpty()); + EXPECT_TRUE(mult.vert_code().isEmpty()); + + const olive::ShaderCode plain = + node.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("other"))); + EXPECT_FALSE(plain.frag_code().isEmpty()); + EXPECT_TRUE(plain.vert_code().isEmpty()); + + // The two requests resolve to different fragment shaders. + EXPECT_NE(mult.frag_code(), plain.frag_code()); +} + +TEST(OpacityEffect, ValueWithoutTexturePushesNothing) +{ + olive::OpacityEffect node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(OpacityEffect, ValueWithFullOpacityPassesTextureThrough) +{ + olive::OpacityEffect node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OpacityEffect::kTextureInput, tex); + row.insert(olive::OpacityEffect::kValueInput, FloatValue(1.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + // 1.0 is a no-op: the input texture is pushed unchanged, not a job. + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(OpacityEffect, ValueWithFractionalOpacityPushesShaderJob) +{ + olive::OpacityEffect node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OpacityEffect::kTextureInput, tex); + row.insert(olive::OpacityEffect::kValueInput, FloatValue(0.5)); + + 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); + + // The default shader (no special ID) is used for a plain float multiply. + EXPECT_TRUE(job->GetShaderID().isEmpty()); + + const olive::NodeValueRow &values = job->GetValues(); + ASSERT_TRUE(values.contains(olive::OpacityEffect::kValueInput)); + EXPECT_DOUBLE_EQ(values.value(olive::OpacityEffect::kValueInput).toDouble(), + 0.5); + EXPECT_EQ(values.value(olive::OpacityEffect::kTextureInput).toTexture(), + tex); +} + +TEST(OpacityEffect, ValueWithTextureOpacityPushesRgbMultJob) +{ + olive::OpacityEffect node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::TexturePtr opacity_tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::OpacityEffect::kTextureInput, tex); + row.insert(olive::OpacityEffect::kValueInput, TextureValue(opacity_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); + + // A texture-valued opacity selects the rgbmult shader. + EXPECT_EQ(job->GetShaderID(), QStringLiteral("rgbmult")); + EXPECT_EQ(job->GetValues() + .value(olive::OpacityEffect::kValueInput) + .toTexture(), + opacity_tex); +} + +// ----------------------------------------------------------------------------- +// BlurFilterNode +// ----------------------------------------------------------------------------- + +TEST(BlurFilterNode, InputDefinitionsAndDefaults) +{ + olive::BlurFilterNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::BlurFilterNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::BlurFilterNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::BlurFilterNode::kTextureInput); + + // Method is a static UI choice defaulting to Gaussian. + EXPECT_EQ(int(node.GetInputDataType(olive::BlurFilterNode::kMethodInput)), + int(olive::NodeValue::kCombo)); + EXPECT_FALSE(node.IsInputKeyframable(olive::BlurFilterNode::kMethodInput)); + EXPECT_FALSE(node.IsInputConnectable(olive::BlurFilterNode::kMethodInput)); + EXPECT_EQ(node.GetStandardValue(olive::BlurFilterNode::kMethodInput).toInt(), + int(olive::BlurFilterNode::kGaussian)); + EXPECT_EQ(int(node.GetMethod()), int(olive::BlurFilterNode::kGaussian)); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::BlurFilterNode::kRadiusInput).toDouble(), + 10.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::BlurFilterNode::kRadiusInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + + EXPECT_TRUE( + node.GetStandardValue(olive::BlurFilterNode::kHorizInput).toBool()); + EXPECT_TRUE( + node.GetStandardValue(olive::BlurFilterNode::kVertInput).toBool()); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::BlurFilterNode::kDirectionalDegreesInput) + .toDouble(), + 0.0); + EXPECT_EQ(node.GetStandardValue(olive::BlurFilterNode::kRadialCenterInput) + .value(), + QVector2D(0.0f, 0.0f)); + EXPECT_TRUE(node.GetStandardValue( + olive::BlurFilterNode::kRepeatEdgePixelsInput) + .toBool()); +} + +TEST(BlurFilterNode, Identity) +{ + olive::BlurFilterNode node; + + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.blur")); + 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::kCategoryFilter)); +} + +TEST(BlurFilterNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::BlurFilterNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kMethodInput), + QStringLiteral("Method")); + EXPECT_EQ(node.GetComboBoxStrings(olive::BlurFilterNode::kMethodInput), + QStringList({ QStringLiteral("Box"), QStringLiteral("Gaussian"), + QStringLiteral("Directional"), + QStringLiteral("Radial") })); + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kRadiusInput), + QStringLiteral("Radius")); + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kHorizInput), + QStringLiteral("Horizontal")); + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kVertInput), + QStringLiteral("Vertical")); + EXPECT_EQ( + node.GetInputName(olive::BlurFilterNode::kRepeatEdgePixelsInput), + QStringLiteral("Repeat Edge Pixels")); + EXPECT_EQ( + node.GetInputName(olive::BlurFilterNode::kDirectionalDegreesInput), + QStringLiteral("Direction")); + EXPECT_EQ(node.GetInputName(olive::BlurFilterNode::kRadialCenterInput), + QStringLiteral("Center")); +} + +TEST(BlurFilterNode, MethodSwitchTogglesInputVisibility) +{ + olive::BlurFilterNode node; + + // Default method (Gaussian) shows the axis toggles only. + EXPECT_FALSE(node.IsInputHidden(olive::BlurFilterNode::kHorizInput)); + EXPECT_FALSE(node.IsInputHidden(olive::BlurFilterNode::kVertInput)); + EXPECT_TRUE( + node.IsInputHidden(olive::BlurFilterNode::kDirectionalDegreesInput)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kRadialCenterInput)); + + node.SetStandardValue(olive::BlurFilterNode::kMethodInput, + int(olive::BlurFilterNode::kDirectional)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kHorizInput)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kVertInput)); + EXPECT_FALSE( + node.IsInputHidden(olive::BlurFilterNode::kDirectionalDegreesInput)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kRadialCenterInput)); + + node.SetStandardValue(olive::BlurFilterNode::kMethodInput, + int(olive::BlurFilterNode::kRadial)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kHorizInput)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kVertInput)); + EXPECT_TRUE( + node.IsInputHidden(olive::BlurFilterNode::kDirectionalDegreesInput)); + EXPECT_FALSE(node.IsInputHidden(olive::BlurFilterNode::kRadialCenterInput)); + + node.SetStandardValue(olive::BlurFilterNode::kMethodInput, + int(olive::BlurFilterNode::kBox)); + EXPECT_FALSE(node.IsInputHidden(olive::BlurFilterNode::kHorizInput)); + EXPECT_FALSE(node.IsInputHidden(olive::BlurFilterNode::kVertInput)); + EXPECT_TRUE( + node.IsInputHidden(olive::BlurFilterNode::kDirectionalDegreesInput)); + EXPECT_TRUE(node.IsInputHidden(olive::BlurFilterNode::kRadialCenterInput)); +} + +TEST(BlurFilterNode, ShaderCodeLoadsFragmentResource) +{ + olive::BlurFilterNode 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(BlurFilterNode, ValueWithoutTexturePushesNothing) +{ + olive::BlurFilterNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(BlurFilterNode, ValueWithZeroRadiusPassesTextureThrough) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kGaussian))); + row.insert(olive::BlurFilterNode::kRadiusInput, FloatValue(0.0)); + row.insert(olive::BlurFilterNode::kHorizInput, BoolValue(true)); + row.insert(olive::BlurFilterNode::kVertInput, BoolValue(true)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + // No radius means no blur: the texture passes through unchanged. + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(BlurFilterNode, ValueWithBothAxesPushesTwoIterationJob) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kGaussian))); + row.insert(olive::BlurFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::BlurFilterNode::kHorizInput, BoolValue(true)); + row.insert(olive::BlurFilterNode::kVertInput, BoolValue(true)); + + 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); + + // Blurring both axes runs the shader twice, feeding the texture input. + EXPECT_EQ(job->GetIterationCount(), 2); + EXPECT_EQ(job->GetIterativeInput(), olive::BlurFilterNode::kTextureInput); + + const olive::NodeValueRow &values = job->GetValues(); + ASSERT_TRUE(values.contains(QStringLiteral("resolution_in"))); + EXPECT_EQ(values.value(QStringLiteral("resolution_in")).toVec2(), + QVector2D(16.0f, 16.0f)); + EXPECT_DOUBLE_EQ( + values.value(olive::BlurFilterNode::kRadiusInput).toDouble(), 10.0); +} + +TEST(BlurFilterNode, ValueWithSingleAxisPushesOneIterationJob) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kGaussian))); + row.insert(olive::BlurFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::BlurFilterNode::kHorizInput, BoolValue(true)); + row.insert(olive::BlurFilterNode::kVertInput, 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_EQ(job->GetIterationCount(), 1); + EXPECT_EQ(job->GetIterativeInput(), olive::BlurFilterNode::kTextureInput); +} + +TEST(BlurFilterNode, ValueWithNoAxesPassesTextureThrough) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kGaussian))); + row.insert(olive::BlurFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::BlurFilterNode::kHorizInput, BoolValue(false)); + row.insert(olive::BlurFilterNode::kVertInput, BoolValue(false)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + // Both axes unchecked disables the blur entirely. + ASSERT_EQ(table.Count(), 1); + const olive::TexturePtr out = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(BlurFilterNode, ValueWithDirectionalMethodPushesJob) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kDirectional))); + row.insert(olive::BlurFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::BlurFilterNode::kDirectionalDegreesInput, + FloatValue(45.0)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + // Directional blur ignores the axis toggles and always runs once. + 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_EQ(job->GetIterationCount(), 1); + EXPECT_DOUBLE_EQ( + job->GetValues() + .value(olive::BlurFilterNode::kDirectionalDegreesInput) + .toDouble(), + 45.0); +} + +TEST(BlurFilterNode, RadialGizmoFollowsCenterAndHalfResolution) +{ + olive::BlurFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + + ASSERT_EQ(node.GetGizmos().size(), 1); + auto *gizmo = static_cast(node.GetGizmos().first()); + ASSERT_NE(gizmo, nullptr); + + olive::NodeValueRow row = + MakeTextureRow(olive::BlurFilterNode::kTextureInput, tex); + row.insert(olive::BlurFilterNode::kMethodInput, + ComboValue(int(olive::BlurFilterNode::kRadial))); + row.insert(olive::BlurFilterNode::kRadialCenterInput, + Vec2Value(QVector2D(3.0f, -2.0f))); + + node.UpdateGizmoPositions(row, olive::NodeGlobals()); + + // The gizmo sits at the center offset from half the texture resolution. + EXPECT_TRUE(gizmo->IsVisible()); + EXPECT_EQ(gizmo->GetPoint(), QPointF(11.0, 6.0)); + EXPECT_EQ(node.GetInputProperty(olive::BlurFilterNode::kRadialCenterInput, + QStringLiteral("offset")) + .value(), + QVector2D(8.0f, 8.0f)); + + // Any other method hides the gizmo again. + row[olive::BlurFilterNode::kMethodInput] = + ComboValue(int(olive::BlurFilterNode::kGaussian)); + node.UpdateGizmoPositions(row, olive::NodeGlobals()); + EXPECT_FALSE(gizmo->IsVisible()); +} + +// ----------------------------------------------------------------------------- +// DropShadowFilter +// ----------------------------------------------------------------------------- + +TEST(DropShadowFilter, InputDefinitionsAndDefaults) +{ + olive::DropShadowFilter node; + + EXPECT_EQ(int(node.GetInputDataType(olive::DropShadowFilter::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::DropShadowFilter::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::DropShadowFilter::kTextureInput); + + // The default shadow is black. + const olive::core::Color color = + node.GetStandardValue(olive::DropShadowFilter::kColorInput) + .value(); + 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(), 1.0f); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::DropShadowFilter::kDistanceInput) + .toDouble(), + 10.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::DropShadowFilter::kAngleInput).toDouble(), + 135.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::DropShadowFilter::kSoftnessInput) + .toDouble(), + 10.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::DropShadowFilter::kSoftnessInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::DropShadowFilter::kOpacityInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::DropShadowFilter::kOpacityInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_EQ(node.GetInputProperty(olive::DropShadowFilter::kOpacityInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); + EXPECT_FALSE( + node.GetStandardValue(olive::DropShadowFilter::kFastInput).toBool()); +} + +TEST(DropShadowFilter, Identity) +{ + olive::DropShadowFilter node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.dropshadow")); + 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::kCategoryFilter)); +} + +TEST(DropShadowFilter, RetranslateSetsInputNames) +{ + olive::DropShadowFilter node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kColorInput), + QStringLiteral("Color")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kDistanceInput), + QStringLiteral("Distance")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kAngleInput), + QStringLiteral("Angle")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kSoftnessInput), + QStringLiteral("Softness")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kOpacityInput), + QStringLiteral("Opacity")); + EXPECT_EQ(node.GetInputName(olive::DropShadowFilter::kFastInput), + QStringLiteral("Faster (Lower Quality)")); +} + +TEST(DropShadowFilter, ShaderCodeLoadsFragmentResource) +{ + olive::DropShadowFilter 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(DropShadowFilter, ValueWithoutTexturePushesNothing) +{ + olive::DropShadowFilter node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(DropShadowFilter, ValueWithZeroSoftnessPushesSingleIterationJob) +{ + olive::DropShadowFilter node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::DropShadowFilter::kTextureInput, tex); + row.insert(olive::DropShadowFilter::kSoftnessInput, FloatValue(0.0)); + + 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); + + // Zero softness skips the blur passes: a single shader iteration remains. + EXPECT_EQ(job->GetIterationCount(), 1); + + const olive::NodeValueRow &values = job->GetValues(); + EXPECT_EQ(values.value(QStringLiteral("resolution_in")).toVec2(), + QVector2D(16.0f, 16.0f)); + // The previous-iteration input is always seeded with the source texture. + EXPECT_EQ(values.value(QStringLiteral("previous_iteration_in")) + .toTexture(), + tex); +} + +TEST(DropShadowFilter, ValueWithSoftnessPushesThreeIterationJob) +{ + olive::DropShadowFilter node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::DropShadowFilter::kTextureInput, tex); + row.insert(olive::DropShadowFilter::kSoftnessInput, FloatValue(10.0)); + + 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); + + // Non-zero softness blurs iteratively over the previous pass. + EXPECT_EQ(job->GetIterationCount(), 3); + EXPECT_EQ(job->GetIterativeInput(), + QStringLiteral("previous_iteration_in")); +} + +// ----------------------------------------------------------------------------- +// MosaicFilterNode +// ----------------------------------------------------------------------------- + +TEST(MosaicFilterNode, InputDefinitionsAndDefaults) +{ + olive::MosaicFilterNode node; + + EXPECT_EQ( + int(node.GetInputDataType(olive::MosaicFilterNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::MosaicFilterNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::MosaicFilterNode::kTextureInput); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MosaicFilterNode::kHorizInput).toDouble(), + 32.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::MosaicFilterNode::kHorizInput, + QStringLiteral("min")) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MosaicFilterNode::kVertInput).toDouble(), + 18.0); + EXPECT_DOUBLE_EQ(node.GetInputProperty(olive::MosaicFilterNode::kVertInput, + QStringLiteral("min")) + .toDouble(), + 1.0); +} + +TEST(MosaicFilterNode, Identity) +{ + olive::MosaicFilterNode node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.mosaicfilter")); + 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::kCategoryFilter)); +} + +TEST(MosaicFilterNode, RetranslateSetsInputNames) +{ + olive::MosaicFilterNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::MosaicFilterNode::kTextureInput), + QStringLiteral("Texture")); + EXPECT_EQ(node.GetInputName(olive::MosaicFilterNode::kHorizInput), + QStringLiteral("Horizontal")); + EXPECT_EQ(node.GetInputName(olive::MosaicFilterNode::kVertInput), + QStringLiteral("Vertical")); +} + +TEST(MosaicFilterNode, ShaderCodeLoadsFragmentResource) +{ + olive::MosaicFilterNode 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(MosaicFilterNode, ValueWithoutTexturePushesNothing) +{ + olive::MosaicFilterNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(MosaicFilterNode, ValueWithMatchingResolutionPassesTextureThrough) +{ + olive::MosaicFilterNode node; + + // A mosaic block size equal to the texture size is a no-op. + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::MosaicFilterNode::kTextureInput, tex); + row.insert(olive::MosaicFilterNode::kHorizInput, FloatValue(16.0)); + row.insert(olive::MosaicFilterNode::kVertInput, FloatValue(16.0)); + + 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); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(MosaicFilterNode, ValuePushesJobWithLinearInterpolation) +{ + olive::MosaicFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::MosaicFilterNode::kTextureInput, tex); + row.insert(olive::MosaicFilterNode::kHorizInput, FloatValue(32.0)); + row.insert(olive::MosaicFilterNode::kVertInput, FloatValue(18.0)); + + 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); + + // Mipmapping would smear the blocks, so the mosaic forces bilinear lookup. + EXPECT_EQ(int(job->GetInterpolation(olive::MosaicFilterNode::kTextureInput)), + int(olive::Texture::kLinear)); +} + +// ----------------------------------------------------------------------------- +// StrokeFilterNode +// ----------------------------------------------------------------------------- + +TEST(StrokeFilterNode, InputDefinitionsAndDefaults) +{ + olive::StrokeFilterNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::StrokeFilterNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::StrokeFilterNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::StrokeFilterNode::kTextureInput); + + // The default stroke is opaque white. + const olive::core::Color color = + node.GetStandardValue(olive::StrokeFilterNode::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_FLOAT_EQ(color.green(), 1.0f); + EXPECT_FLOAT_EQ(color.blue(), 1.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::StrokeFilterNode::kRadiusInput).toDouble(), + 10.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::StrokeFilterNode::kRadiusInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::StrokeFilterNode::kOpacityInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::StrokeFilterNode::kOpacityInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::StrokeFilterNode::kOpacityInput, + QStringLiteral("max")) + .toDouble(), + 1.0); + EXPECT_EQ(node.GetInputProperty(olive::StrokeFilterNode::kOpacityInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); + EXPECT_FALSE( + node.GetStandardValue(olive::StrokeFilterNode::kInnerInput).toBool()); +} + +TEST(StrokeFilterNode, Identity) +{ + olive::StrokeFilterNode node; + + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.stroke")); + 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::kCategoryFilter)); +} + +TEST(StrokeFilterNode, RetranslateSetsInputNames) +{ + olive::StrokeFilterNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::StrokeFilterNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::StrokeFilterNode::kColorInput), + QStringLiteral("Color")); + EXPECT_EQ(node.GetInputName(olive::StrokeFilterNode::kRadiusInput), + QStringLiteral("Radius")); + EXPECT_EQ(node.GetInputName(olive::StrokeFilterNode::kOpacityInput), + QStringLiteral("Opacity")); + EXPECT_EQ(node.GetInputName(olive::StrokeFilterNode::kInnerInput), + QStringLiteral("Inner")); +} + +TEST(StrokeFilterNode, ShaderCodeLoadsFragmentResource) +{ + olive::StrokeFilterNode 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(StrokeFilterNode, ValueWithoutTexturePushesNothing) +{ + olive::StrokeFilterNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(StrokeFilterNode, ValueWithRadiusAndOpacityPushesJob) +{ + olive::StrokeFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::StrokeFilterNode::kTextureInput, tex); + row.insert(olive::StrokeFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::StrokeFilterNode::kOpacityInput, FloatValue(1.0)); + + 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_EQ(job->GetValues() + .value(QStringLiteral("resolution_in")) + .toVec2(), + QVector2D(16.0f, 16.0f)); +} + +TEST(StrokeFilterNode, ValueWithZeroRadiusPassesTextureThrough) +{ + olive::StrokeFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::StrokeFilterNode::kTextureInput, tex); + row.insert(olive::StrokeFilterNode::kRadiusInput, FloatValue(0.0)); + row.insert(olive::StrokeFilterNode::kOpacityInput, FloatValue(1.0)); + + 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); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +TEST(StrokeFilterNode, ValueWithZeroOpacityPassesTextureThrough) +{ + olive::StrokeFilterNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::StrokeFilterNode::kTextureInput, tex); + row.insert(olive::StrokeFilterNode::kRadiusInput, FloatValue(10.0)); + row.insert(olive::StrokeFilterNode::kOpacityInput, FloatValue(0.0)); + + 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); + EXPECT_EQ(out, tex); + EXPECT_FALSE(out->IsJob()); +} + +// ----------------------------------------------------------------------------- +// ChromaKeyNode +// ----------------------------------------------------------------------------- + +TEST(ChromaKeyNode, InputDefinitionsAndDefaults) +{ + olive::ChromaKeyNode node; + + // The texture input comes from OCIOBaseNode and is the effect input. + EXPECT_TRUE(node.HasInputWithID(olive::OCIOBaseNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::OCIOBaseNode::kTextureInput); + + // The default key color is pure green. + const olive::core::Color color = + node.GetStandardValue(olive::ChromaKeyNode::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 0.0f); + EXPECT_FLOAT_EQ(color.green(), 1.0f); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ChromaKeyNode::kLowerToleranceInput) + .toDouble(), + 5.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ChromaKeyNode::kLowerToleranceInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ChromaKeyNode::kLowerToleranceInput, + QStringLiteral("base")) + .toDouble(), + 0.1); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ChromaKeyNode::kUpperToleranceInput) + .toDouble(), + 25.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ChromaKeyNode::kUpperToleranceInput, + QStringLiteral("base")) + .toDouble(), + 0.1); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ChromaKeyNode::kHighlightsInput) + .toDouble(), + 100.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ChromaKeyNode::kShadowsInput).toDouble(), + 100.0); + + EXPECT_EQ(int(node.GetInputDataType( + olive::ChromaKeyNode::kGarbageMatteInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::ChromaKeyNode::kGarbageMatteInput)); + EXPECT_EQ( + int(node.GetInputDataType(olive::ChromaKeyNode::kCoreMatteInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::ChromaKeyNode::kCoreMatteInput)); + + EXPECT_FALSE( + node.GetStandardValue(olive::ChromaKeyNode::kInvertInput).toBool()); + EXPECT_FALSE( + node.GetStandardValue(olive::ChromaKeyNode::kMaskOnlyInput).toBool()); +} + +TEST(ChromaKeyNode, Identity) +{ + olive::ChromaKeyNode node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.chromakey")); + 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::kCategoryKeying)); +} + +TEST(ChromaKeyNode, RetranslateSetsInputNames) +{ + olive::ChromaKeyNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::OCIOBaseNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kGarbageMatteInput), + QStringLiteral("Garbage Matte")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kCoreMatteInput), + QStringLiteral("Core Matte")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kColorInput), + QStringLiteral("Key Color")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kShadowsInput), + QStringLiteral("Shadows")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kHighlightsInput), + QStringLiteral("Highlights")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kUpperToleranceInput), + QStringLiteral("Upper Tolerance")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kLowerToleranceInput), + QStringLiteral("Lower Tolerance")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kInvertInput), + QStringLiteral("Invert Mask")); + EXPECT_EQ(node.GetInputName(olive::ChromaKeyNode::kMaskOnlyInput), + QStringLiteral("Show Mask Only")); +} + +TEST(ChromaKeyNode, ShaderCodeSubstitutesStub) +{ + olive::ChromaKeyNode node; + + // The fragment shader contains a %1 placeholder for OCIO-generated code, + // which GetShaderCode fills with the request's stub. + const olive::ShaderCode with_stub = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("test"), + QStringLiteral("OAK_TEST_STUB"))); + EXPECT_TRUE(with_stub.frag_code().contains(QStringLiteral("OAK_TEST_STUB"))); + + const olive::ShaderCode no_stub = + node.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("test"))); + EXPECT_FALSE(no_stub.frag_code().isEmpty()); + EXPECT_FALSE(no_stub.frag_code().contains(QStringLiteral("%1"))); +} + +TEST(ChromaKeyNode, 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::ChromaKeyNode 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(ChromaKeyNode, ValueInProjectPushesColorTransformJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + auto *node = new olive::ChromaKeyNode(); + node->setParent(&project); + + 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::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); + + // Adding the node to the project generates its XYZ processor. + EXPECT_NE(job->GetColorProcessor(), nullptr); + EXPECT_EQ(job->GetFunctionName(), + QStringLiteral("SceneLinearToCIEXYZ_d65")); + EXPECT_EQ(job->CustomShaderSource(), node); + EXPECT_EQ(job->GetInputTexture().toTexture(), tex); +} + +// ----------------------------------------------------------------------------- +// ColorDifferenceKeyNode +// ----------------------------------------------------------------------------- + +TEST(ColorDifferenceKeyNode, InputDefinitionsAndDefaults) +{ + olive::ColorDifferenceKeyNode node; + + EXPECT_EQ(int(node.GetInputDataType( + olive::ColorDifferenceKeyNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable( + olive::ColorDifferenceKeyNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), + olive::ColorDifferenceKeyNode::kTextureInput); + + EXPECT_EQ(int(node.GetInputDataType( + olive::ColorDifferenceKeyNode::kGarbageMatteInput)), + int(olive::NodeValue::kTexture)); + EXPECT_EQ(int(node.GetInputDataType( + olive::ColorDifferenceKeyNode::kCoreMatteInput)), + int(olive::NodeValue::kTexture)); + + // Key color is a static combo defaulting to the first entry (green). + EXPECT_EQ(int(node.GetInputDataType( + olive::ColorDifferenceKeyNode::kColorInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node.GetStandardValue(olive::ColorDifferenceKeyNode::kColorInput) + .toInt(), + 0); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ColorDifferenceKeyNode::kHighlightsInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ColorDifferenceKeyNode::kHighlightsInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ColorDifferenceKeyNode::kHighlightsInput, + QStringLiteral("base")) + .toDouble(), + 0.01); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::ColorDifferenceKeyNode::kShadowsInput) + .toDouble(), + 1.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ColorDifferenceKeyNode::kShadowsInput, + QStringLiteral("min")) + .toDouble(), + 0.0); + EXPECT_DOUBLE_EQ( + node.GetInputProperty(olive::ColorDifferenceKeyNode::kShadowsInput, + QStringLiteral("base")) + .toDouble(), + 0.01); + + EXPECT_FALSE(node.GetStandardValue( + olive::ColorDifferenceKeyNode::kMaskOnlyInput) + .toBool()); +} + +TEST(ColorDifferenceKeyNode, Identity) +{ + olive::ColorDifferenceKeyNode node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.colordifferencekey")); + 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::kCategoryKeying)); +} + +TEST(ColorDifferenceKeyNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::ColorDifferenceKeyNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::ColorDifferenceKeyNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ( + node.GetInputName(olive::ColorDifferenceKeyNode::kGarbageMatteInput), + QStringLiteral("Garbage Matte")); + EXPECT_EQ(node.GetInputName(olive::ColorDifferenceKeyNode::kCoreMatteInput), + QStringLiteral("Core Matte")); + EXPECT_EQ(node.GetInputName(olive::ColorDifferenceKeyNode::kColorInput), + QStringLiteral("Key Color")); + EXPECT_EQ(node.GetComboBoxStrings( + olive::ColorDifferenceKeyNode::kColorInput), + QStringList( + { QStringLiteral("Green"), QStringLiteral("Blue") })); + EXPECT_EQ(node.GetInputName(olive::ColorDifferenceKeyNode::kShadowsInput), + QStringLiteral("Shadows")); + EXPECT_EQ( + node.GetInputName(olive::ColorDifferenceKeyNode::kHighlightsInput), + QStringLiteral("Highlights")); + EXPECT_EQ(node.GetInputName(olive::ColorDifferenceKeyNode::kMaskOnlyInput), + QStringLiteral("Show Mask Only")); +} + +TEST(ColorDifferenceKeyNode, ShaderCodeLoadsFragmentResource) +{ + olive::ColorDifferenceKeyNode 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(ColorDifferenceKeyNode, ValueWithoutTexturePushesNothing) +{ + olive::ColorDifferenceKeyNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(ColorDifferenceKeyNode, ValuePushesShaderJobWithRowValues) +{ + olive::ColorDifferenceKeyNode node; + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::ColorDifferenceKeyNode::kTextureInput, tex); + row.insert(olive::ColorDifferenceKeyNode::kColorInput, ComboValue(1)); + row.insert(olive::ColorDifferenceKeyNode::kMaskOnlyInput, BoolValue(true)); + + 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); + + // The whole input row is forwarded into the job. + const olive::NodeValueRow &values = job->GetValues(); + EXPECT_EQ(values.value(olive::ColorDifferenceKeyNode::kTextureInput) + .toTexture(), + tex); + EXPECT_EQ(values.value(olive::ColorDifferenceKeyNode::kColorInput).toInt(), + 1); + EXPECT_TRUE(values.value(olive::ColorDifferenceKeyNode::kMaskOnlyInput) + .toBool()); +} + +// ----------------------------------------------------------------------------- +// DespillNode +// ----------------------------------------------------------------------------- + +TEST(DespillNode, InputDefinitionsAndDefaults) +{ + olive::DespillNode node; + + EXPECT_EQ(int(node.GetInputDataType(olive::DespillNode::kTextureInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node.IsInputKeyframable(olive::DespillNode::kTextureInput)); + EXPECT_EQ(node.GetEffectInputID(), olive::DespillNode::kTextureInput); + + EXPECT_EQ(int(node.GetInputDataType(olive::DespillNode::kColorInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node.GetStandardValue(olive::DespillNode::kColorInput).toInt(), + 0); + EXPECT_EQ(int(node.GetInputDataType(olive::DespillNode::kMethodInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node.GetStandardValue(olive::DespillNode::kMethodInput).toInt(), + 0); + EXPECT_FALSE(node.GetStandardValue( + olive::DespillNode::kPreserveLuminanceInput) + .toBool()); +} + +TEST(DespillNode, Identity) +{ + olive::DespillNode node; + + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.despill")); + 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::kCategoryKeying)); +} + +TEST(DespillNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::DespillNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::DespillNode::kTextureInput), + QStringLiteral("Input")); + EXPECT_EQ(node.GetInputName(olive::DespillNode::kColorInput), + QStringLiteral("Key Color")); + EXPECT_EQ(node.GetComboBoxStrings(olive::DespillNode::kColorInput), + QStringList( + { QStringLiteral("Green"), QStringLiteral("Blue") })); + EXPECT_EQ(node.GetInputName(olive::DespillNode::kMethodInput), + QStringLiteral("Method")); + EXPECT_EQ(node.GetComboBoxStrings(olive::DespillNode::kMethodInput), + QStringList({ QStringLiteral("Average"), + QStringLiteral("Double Red Average"), + QStringLiteral("Double Average"), + QStringLiteral("Limit") })); + EXPECT_EQ(node.GetInputName(olive::DespillNode::kPreserveLuminanceInput), + QStringLiteral("Preserve Luminance")); +} + +TEST(DespillNode, ShaderCodeLoadsFragmentResource) +{ + olive::DespillNode 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(DespillNode, ValueInProjectWithoutTexturePushesNothing) +{ + // DespillNode::Value() unconditionally queries the project's color + // manager, so it can only be exercised with the node in a project. + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + auto *node = new olive::DespillNode(); + node->setParent(&project); + + olive::NodeValueTable table; + node->Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(DespillNode, ValueInProjectPushesJobWithLumaCoefficients) +{ + olive::ColorManager::SetUpDefaultConfig(); + + olive::Project project; + project.Initialize(); + + auto *node = new olive::DespillNode(); + node->setParent(&project); + + olive::TexturePtr tex = MakeDummyTexture(); + olive::NodeValueRow row = + MakeTextureRow(olive::DespillNode::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); + + // The job carries the color manager's default luma coefficients. + double expected[3] = { 0.0, 0.0, 0.0 }; + project.color_manager()->GetDefaultLumaCoefs(expected); + + const olive::NodeValueRow &values = job->GetValues(); + ASSERT_TRUE(values.contains(QStringLiteral("luma_coeffs"))); + const QVector3D coeffs = + values.value(QStringLiteral("luma_coeffs")).toVec3(); + EXPECT_FLOAT_EQ(coeffs.x(), float(expected[0])); + EXPECT_FLOAT_EQ(coeffs.y(), float(expected[1])); + EXPECT_FLOAT_EQ(coeffs.z(), float(expected[2])); + + EXPECT_EQ(values.value(olive::DespillNode::kTextureInput).toTexture(), + tex); +} diff --git a/tests/gtest/node_math_transition_test.cpp b/tests/gtest/node_math_transition_test.cpp new file mode 100644 index 000000000..e41290c91 --- /dev/null +++ b/tests/gtest/node_math_transition_test.cpp @@ -0,0 +1,1102 @@ +#include + +#include + +#include + +#include "node/block/clip/clip.h" +#include "node/block/subtitle/subtitle.h" +#include "node/block/transition/crossdissolve/crossdissolvetransition.h" +#include "node/block/transition/diptocolor/diptocolortransition.h" +#include "node/color/colormanager/colormanager.h" +#include "node/globals.h" +#include "node/math/merge/merge.h" +#include "node/math/trigonometry/trigonometry.h" +#include "node/project.h" +#include "node/traverser.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "olive/core/util/color.h" +#include "olive/core/util/rational.h" +#include "olive/core/util/timerange.h" +#include "render/job/shaderjob.h" +#include "render/loopmode.h" +#include "render/texture.h" + +namespace +{ + +constexpr double kPi = 3.14159265358979323846; + +// A "dummy" texture has no renderer backend and is therefore safe to pass +// around in a headless, CPU-only test. +olive::TexturePtr +MakeDummyTexture(int channels = olive::VideoParams::kRGBAChannelCount) +{ + return std::make_shared( + olive::VideoParams(64, 64, olive::core::PixelFormat::F32, channels)); +} + +olive::core::AudioParams TestAudioParams() +{ + return olive::core::AudioParams(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); +} + +// Stereo buffer with every sample in both channels set to the same value +olive::core::SampleBuffer MakeConstantBuffer(float value, size_t sample_count) +{ + olive::core::SampleBuffer buffer(TestAudioParams(), sample_count); + for (size_t i = 0; i < sample_count; i++) { + buffer.data(0)[i] = value; + buffer.data(1)[i] = value; + } + return buffer; +} + +olive::NodeValue SampleValue(const olive::core::SampleBuffer &buffer) +{ + return olive::NodeValue(olive::NodeValue::kSamples, + QVariant::fromValue(buffer)); +} + +// Globals for the audio path of TransitionBlock::Value, mixing over [in, out) +olive::NodeGlobals AudioGlobals(const olive::core::rational &in, + const olive::core::rational &out) +{ + return olive::NodeGlobals(olive::VideoParams(), TestAudioParams(), + olive::TimeRange(in, out), + olive::LoopMode::kLoopModeOff); +} + +// A fresh traverser per call: NodeTraverser caches tables per node/range, so +// reusing one would return stale results after changing standard values. +double GenerateTrigResult(olive::TrigonometryNode *node) +{ + olive::NodeTraverser traverser; + olive::NodeValueTable table = traverser.GenerateTable( + node, olive::TimeRange(olive::core::rational(0), + olive::core::rational(1, 30))); + return table.Get(olive::NodeValue::kFloat).toDouble(); +} + +template T *AddNode(olive::Project *project) +{ + T *node = new T(); + node->setParent(project); + return node; +} + +// Records the video/audio params seen in NodeGlobals so the traverser's +// NodeGlobals construction can be observed. +class GlobalsProbeNode : public olive::Node { +public: + GlobalsProbeNode() = default; + + NODE_DEFAULT_FUNCTIONS(GlobalsProbeNode) + + virtual QString Name() const override + { + return QStringLiteral("Globals Probe"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.globalsprobe"); + } + + 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) + + last_vparams_ = globals.vparams(); + last_aparams_ = globals.aparams(); + table->Push(olive::NodeValue::kFloat, 0.0, this); + } + + const olive::VideoParams &last_vparams() const + { + return last_vparams_; + } + + const olive::core::AudioParams &last_aparams() const + { + return last_aparams_; + } + +private: + mutable olive::VideoParams last_vparams_; + mutable olive::core::AudioParams last_aparams_; +}; + +} // namespace + +// ----------------------------------------------------------------------------- +// TrigonometryNode +// ----------------------------------------------------------------------------- + +TEST(TrigonometryNode, MetadataIsCorrect) +{ + olive::TrigonometryNode node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.trigonometry")); + EXPECT_EQ(node.Name(), QStringLiteral("Trigonometry")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryMath)); + + EXPECT_EQ(int(node.GetInputDataType(olive::TrigonometryNode::kMethodIn)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(int(node.GetInputDataType(olive::TrigonometryNode::kXIn)), + int(olive::NodeValue::kFloat)); + + // Method defaults to sine, value defaults to zero + EXPECT_EQ(node.GetStandardValue(olive::TrigonometryNode::kMethodIn).toInt(), + 0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::TrigonometryNode::kXIn).toDouble(), 0.0); + + // The method is a static UI choice: neither connectable nor keyframable + EXPECT_FALSE( + node.IsInputConnectable(olive::TrigonometryNode::kMethodIn)); + EXPECT_FALSE( + node.IsInputKeyframable(olive::TrigonometryNode::kMethodIn)); +} + +TEST(TrigonometryNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::TrigonometryNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::TrigonometryNode::kMethodIn), + QStringLiteral("Method")); + EXPECT_EQ(node.GetInputName(olive::TrigonometryNode::kXIn), + QStringLiteral("Value")); + + const QStringList methods = + node.GetInputProperty(olive::TrigonometryNode::kMethodIn, + QStringLiteral("combo_str")) + .toStringList(); + ASSERT_EQ(methods.size(), 11); + EXPECT_EQ(methods.at(0), QStringLiteral("Sine")); + EXPECT_EQ(methods.at(1), QStringLiteral("Cosine")); + EXPECT_EQ(methods.at(2), QStringLiteral("Tangent")); + EXPECT_TRUE(methods.at(3).isEmpty()); + EXPECT_EQ(methods.at(4), QStringLiteral("Inverse Sine")); + EXPECT_EQ(methods.at(5), QStringLiteral("Inverse Cosine")); + EXPECT_EQ(methods.at(6), QStringLiteral("Inverse Tangent")); + EXPECT_TRUE(methods.at(7).isEmpty()); + EXPECT_EQ(methods.at(8), QStringLiteral("Hyperbolic Sine")); + EXPECT_EQ(methods.at(9), QStringLiteral("Hyperbolic Cosine")); + EXPECT_EQ(methods.at(10), QStringLiteral("Hyperbolic Tangent")); + + // NOTE: The combo list contains separator entries at indexes 3 and 7 that + // the Operation enum used by Value() does not have, so combo indexes 4 + // and above no longer match the enum (suspected bug, documented here and + // in ComboIndexBeyondSeparatorComputesWrongFunction). +} + +TEST(TrigonometryNode, SineCosineTangent) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 0); + node->SetStandardValue(olive::TrigonometryNode::kXIn, kPi / 2); + EXPECT_NEAR(GenerateTrigResult(node), 1.0, 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 1); + node->SetStandardValue(olive::TrigonometryNode::kXIn, kPi); + EXPECT_NEAR(GenerateTrigResult(node), -1.0, 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 2); + node->SetStandardValue(olive::TrigonometryNode::kXIn, kPi / 4); + EXPECT_NEAR(GenerateTrigResult(node), 1.0, 1e-12); +} + +TEST(TrigonometryNode, InverseOperations) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 3); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0); + EXPECT_NEAR(GenerateTrigResult(node), kPi / 2, 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 4); + node->SetStandardValue(olive::TrigonometryNode::kXIn, -1.0); + EXPECT_NEAR(GenerateTrigResult(node), kPi, 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 5); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0); + EXPECT_NEAR(GenerateTrigResult(node), kPi / 4, 1e-12); +} + +TEST(TrigonometryNode, HyperbolicOperations) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 6); + EXPECT_NEAR(GenerateTrigResult(node), std::sinh(1.0), 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 7); + EXPECT_NEAR(GenerateTrigResult(node), std::cosh(1.0), 1e-12); + + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 8); + EXPECT_NEAR(GenerateTrigResult(node), std::tanh(1.0), 1e-12); +} + +TEST(TrigonometryNode, ComboIndexBeyondSeparatorComputesWrongFunction) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + // NOTE: The combo box labels index 4 as "Inverse Sine", but Value() + // casts the index to the Operation enum where 4 is kOpArcCosine, because + // the combo string list contains separator entries that the enum does + // not have (suspected bug, test documents current behavior). + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 4); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 0.5); + EXPECT_NEAR(GenerateTrigResult(node), std::acos(0.5), 1e-12); + + // Index 8 is labeled "Hyperbolic Sine" but computes hyperbolic tangent + node->SetStandardValue(olive::TrigonometryNode::kMethodIn, 8); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0); + EXPECT_NEAR(GenerateTrigResult(node), std::tanh(1.0), 1e-12); +} + +// ----------------------------------------------------------------------------- +// MergeNode +// ----------------------------------------------------------------------------- + +TEST(MergeNode, MetadataIsCorrect) +{ + olive::MergeNode node; + + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.merge")); + EXPECT_EQ(node.Name(), QStringLiteral("Merge")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryMath)); + + EXPECT_EQ(int(node.GetInputDataType(olive::MergeNode::kBaseIn)), + int(olive::NodeValue::kTexture)); + EXPECT_EQ(int(node.GetInputDataType(olive::MergeNode::kBlendIn)), + int(olive::NodeValue::kTexture)); + + // Textures cannot be keyframed, and the merge node is an internal + // compositing building block hidden from the param view + EXPECT_FALSE(node.IsInputKeyframable(olive::MergeNode::kBaseIn)); + EXPECT_FALSE(node.IsInputKeyframable(olive::MergeNode::kBlendIn)); + EXPECT_TRUE(node.GetFlags() & olive::Node::kDontShowInParamView); +} + +TEST(MergeNode, RetranslateSetsInputNames) +{ + olive::MergeNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::MergeNode::kBaseIn), + QStringLiteral("Base")); + EXPECT_EQ(node.GetInputName(olive::MergeNode::kBlendIn), + QStringLiteral("Blend")); +} + +TEST(MergeNode, ShaderCodeLoadsAlphaOver) +{ + olive::MergeNode node; + + const olive::ShaderCode code = + node.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("x"))); + EXPECT_FALSE(code.frag_code().isEmpty()); +} + +TEST(MergeNode, ValueWithNoTexturesPushesNothing) +{ + olive::MergeNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +TEST(MergeNode, ValueWithOnlyBasePassesBaseThrough) +{ + olive::MergeNode node; + + olive::TexturePtr base = MakeDummyTexture(); + olive::NodeValueRow row; + row.insert(olive::MergeNode::kBaseIn, + olive::NodeValue(olive::NodeValue::kTexture, base)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Get(olive::NodeValue::kTexture).toTexture(), base); +} + +TEST(MergeNode, ValueWithOnlyBlendPassesBlendThrough) +{ + olive::MergeNode node; + + olive::TexturePtr blend = MakeDummyTexture(); + olive::NodeValueRow row; + row.insert(olive::MergeNode::kBlendIn, + olive::NodeValue(olive::NodeValue::kTexture, blend)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Get(olive::NodeValue::kTexture).toTexture(), blend); +} + +TEST(MergeNode, ValueWithRgbBlendPassesBlendThrough) +{ + olive::MergeNode node; + + // An RGB blend texture has no alpha channel, so alpha-over is skipped + // and the blend passes through even when a base is present + olive::TexturePtr base = MakeDummyTexture(); + olive::TexturePtr blend = MakeDummyTexture(3); + olive::NodeValueRow row; + row.insert(olive::MergeNode::kBaseIn, + olive::NodeValue(olive::NodeValue::kTexture, base)); + row.insert(olive::MergeNode::kBlendIn, + olive::NodeValue(olive::NodeValue::kTexture, blend)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Get(olive::NodeValue::kTexture).toTexture(), blend); +} + +TEST(MergeNode, ValueWithBaseAndRgbaBlendPushesAlphaOverJob) +{ + olive::MergeNode node; + + olive::TexturePtr base = MakeDummyTexture(); + olive::TexturePtr blend = MakeDummyTexture(); + olive::NodeValueRow row; + row.insert(olive::MergeNode::kBaseIn, + olive::NodeValue(olive::NodeValue::kTexture, base)); + row.insert(olive::MergeNode::kBlendIn, + olive::NodeValue(olive::NodeValue::kTexture, blend)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + olive::TexturePtr out = table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(out); + ASSERT_TRUE(out->IsJob()); + + auto *job = dynamic_cast(out->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(olive::MergeNode::kBaseIn).toTexture(), base); + EXPECT_EQ(job->Get(olive::MergeNode::kBlendIn).toTexture(), blend); + + // The job texture inherits the base texture's parameters + EXPECT_EQ(out->params().width(), base->params().width()); + EXPECT_EQ(out->params().height(), base->params().height()); +} + +// ----------------------------------------------------------------------------- +// TransitionBlock (through the concrete CrossDissolveTransition and +// DipToColorTransition subclasses) +// ----------------------------------------------------------------------------- + +TEST(TransitionBlock, MetadataIsCorrect) +{ + olive::CrossDissolveTransition cross; + EXPECT_EQ(cross.id(), + QStringLiteral("org.olivevideoeditor.Olive.crossdissolve")); + EXPECT_EQ(cross.Name(), QStringLiteral("Cross Dissolve")); + EXPECT_FALSE(cross.Description().isEmpty()); + EXPECT_TRUE(cross.Category().contains(olive::Node::kCategoryTransition)); + + olive::DipToColorTransition dip; + EXPECT_EQ(dip.id(), + QStringLiteral("org.olivevideoeditor.Olive.diptocolor")); + EXPECT_EQ(dip.Name(), QStringLiteral("Dip To Color")); + EXPECT_FALSE(dip.Description().isEmpty()); + EXPECT_TRUE(dip.Category().contains(olive::Node::kCategoryTransition)); + + EXPECT_EQ(int(cross.GetInputDataType( + olive::TransitionBlock::kOutBlockInput)), + int(olive::NodeValue::kNone)); + EXPECT_EQ(int(cross.GetInputDataType( + olive::TransitionBlock::kInBlockInput)), + int(olive::NodeValue::kNone)); + EXPECT_EQ( + int(cross.GetInputDataType(olive::TransitionBlock::kCurveInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ( + int(cross.GetInputDataType(olive::TransitionBlock::kCenterInput)), + int(olive::NodeValue::kRational)); + + // The curve is a static UI choice defaulting to linear + EXPECT_FALSE( + cross.IsInputConnectable(olive::TransitionBlock::kCurveInput)); + EXPECT_FALSE( + cross.IsInputKeyframable(olive::TransitionBlock::kCurveInput)); + EXPECT_EQ(cross.GetStandardValue(olive::TransitionBlock::kCurveInput) + .toInt(), + 0); + EXPECT_EQ(cross.offset_center(), olive::core::rational(0)); + + // Blocks hide from the param view by default, transitions re-enable it + EXPECT_FALSE(cross.GetFlags() & olive::Node::kDontShowInParamView); + + // Dip To Color adds a color parameter defaulting to black + const olive::core::Color color = + dip.GetStandardValue(olive::DipToColorTransition::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 0.0f); + EXPECT_FLOAT_EQ(color.green(), 0.0f); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); +} + +TEST(TransitionBlock, RetranslateSetsInputNamesAndCurveStrings) +{ + olive::CrossDissolveTransition cross; + cross.Retranslate(); + + EXPECT_EQ(cross.GetInputName(olive::TransitionBlock::kOutBlockInput), + QStringLiteral("From")); + EXPECT_EQ(cross.GetInputName(olive::TransitionBlock::kInBlockInput), + QStringLiteral("To")); + EXPECT_EQ(cross.GetInputName(olive::TransitionBlock::kCurveInput), + QStringLiteral("Curve")); + EXPECT_EQ(cross.GetInputName(olive::TransitionBlock::kCenterInput), + QStringLiteral("Center Offset")); + + const QStringList curves = + cross.GetInputProperty(olive::TransitionBlock::kCurveInput, + QStringLiteral("combo_str")) + .toStringList(); + ASSERT_EQ(curves.size(), 3); + EXPECT_EQ(curves.at(0), QStringLiteral("Linear")); + EXPECT_EQ(curves.at(1), QStringLiteral("Exponential")); + EXPECT_EQ(curves.at(2), QStringLiteral("Logarithmic")); + + olive::DipToColorTransition dip; + dip.Retranslate(); + EXPECT_EQ(dip.GetInputName(olive::DipToColorTransition::kColorInput), + QStringLiteral("Color")); +} + +TEST(TransitionBlock, ShaderCodeLoadsTransitionShaders) +{ + olive::CrossDissolveTransition cross; + EXPECT_FALSE( + cross.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("x"))) + .frag_code() + .isEmpty()); + + olive::DipToColorTransition dip; + EXPECT_FALSE( + dip.GetShaderCode(olive::Node::ShaderRequest(QStringLiteral("x"))) + .frag_code() + .isEmpty()); +} + +TEST(TransitionBlock, OffsetsWithoutConnectedBlocksAreZero) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(2)); + + EXPECT_FALSE(trans.is_dual_transition()); + EXPECT_EQ(trans.connected_out_block(), nullptr); + EXPECT_EQ(trans.connected_in_block(), nullptr); + EXPECT_EQ(trans.in_offset(), olive::core::rational(0)); + EXPECT_EQ(trans.out_offset(), olive::core::rational(0)); + + // With zero offsets only the total progress is meaningful + EXPECT_DOUBLE_EQ(trans.GetTotalProgress(0.5), 0.25); + EXPECT_DOUBLE_EQ(trans.GetOutProgress(0.5), 0.0); + EXPECT_DOUBLE_EQ(trans.GetInProgress(0.5), 0.0); +} + +TEST(TransitionBlock, DualTransitionOffsetsAndProgress) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *trans = AddNode(&project); + auto *out_clip = AddNode(&project); + auto *in_clip = AddNode(&project); + + trans->set_length_and_media_out(olive::core::rational(2)); + + olive::Node::ConnectEdge( + out_clip, + olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + olive::Node::ConnectEdge( + in_clip, olive::NodeInput(trans, olive::TransitionBlock::kInBlockInput)); + + ASSERT_TRUE(trans->is_dual_transition()); + EXPECT_EQ(trans->connected_out_block(), out_clip); + EXPECT_EQ(trans->connected_in_block(), in_clip); + + // A centered dual transition splits its length evenly on both sides + EXPECT_EQ(trans->in_offset(), olive::core::rational(1)); + EXPECT_EQ(trans->out_offset(), olive::core::rational(1)); + + EXPECT_DOUBLE_EQ(trans->GetTotalProgress(0.0), 0.0); + EXPECT_DOUBLE_EQ(trans->GetTotalProgress(0.5), 0.25); + EXPECT_DOUBLE_EQ(trans->GetTotalProgress(1.5), 0.75); + + // Out progress runs from 1 to 0 over the out offset + EXPECT_DOUBLE_EQ(trans->GetOutProgress(0.0), 1.0); + EXPECT_DOUBLE_EQ(trans->GetOutProgress(0.5), 0.5); + EXPECT_DOUBLE_EQ(trans->GetOutProgress(1.5), 0.0); + + // In progress runs from 0 to 1 over the in offset and is clamped + EXPECT_DOUBLE_EQ(trans->GetInProgress(0.5), 0.0); + EXPECT_DOUBLE_EQ(trans->GetInProgress(1.5), 0.5); + EXPECT_DOUBLE_EQ(trans->GetInProgress(2.5), 1.0); +} + +TEST(TransitionBlock, OffsetCenterShiftsInOutOffsets) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *trans = AddNode(&project); + auto *out_clip = AddNode(&project); + auto *in_clip = AddNode(&project); + + trans->set_length_and_media_out(olive::core::rational(2)); + olive::Node::ConnectEdge( + out_clip, + olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + olive::Node::ConnectEdge( + in_clip, olive::NodeInput(trans, olive::TransitionBlock::kInBlockInput)); + + trans->set_offset_center(olive::core::rational(1, 2)); + EXPECT_EQ(trans->offset_center(), olive::core::rational(1, 2)); + + // A positive center offset moves the midpoint towards the out clip + EXPECT_EQ(trans->in_offset(), olive::core::rational(3, 2)); + EXPECT_EQ(trans->out_offset(), olive::core::rational(1, 2)); + + EXPECT_DOUBLE_EQ(trans->GetOutProgress(0.25), 0.5); + EXPECT_DOUBLE_EQ(trans->GetInProgress(1.25), 0.5); +} + +TEST(TransitionBlock, SingleSidedTransitionOffsets) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + // Only an outgoing clip: the whole length is the out offset (fade out) + auto *out_trans = AddNode(&project); + auto *out_clip = AddNode(&project); + out_trans->set_length_and_media_out(olive::core::rational(2)); + olive::Node::ConnectEdge( + out_clip, + olive::NodeInput(out_trans, olive::TransitionBlock::kOutBlockInput)); + + EXPECT_FALSE(out_trans->is_dual_transition()); + EXPECT_EQ(out_trans->out_offset(), olive::core::rational(2)); + EXPECT_EQ(out_trans->in_offset(), olive::core::rational(0)); + EXPECT_DOUBLE_EQ(out_trans->GetOutProgress(1.0), 0.5); + EXPECT_DOUBLE_EQ(out_trans->GetInProgress(1.0), 0.0); + + // Only an incoming clip: the whole length is the in offset (fade in) + auto *in_trans = AddNode(&project); + auto *in_clip = AddNode(&project); + in_trans->set_length_and_media_out(olive::core::rational(2)); + olive::Node::ConnectEdge( + in_clip, + olive::NodeInput(in_trans, olive::TransitionBlock::kInBlockInput)); + + EXPECT_FALSE(in_trans->is_dual_transition()); + EXPECT_EQ(in_trans->in_offset(), olive::core::rational(2)); + EXPECT_EQ(in_trans->out_offset(), olive::core::rational(0)); + EXPECT_DOUBLE_EQ(in_trans->GetInProgress(1.0), 0.5); + EXPECT_DOUBLE_EQ(in_trans->GetOutProgress(1.0), 0.0); +} + +TEST(TransitionBlock, SetOffsetsAndLengthSetsLengthAndCenter) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *trans = AddNode(&project); + auto *out_clip = AddNode(&project); + auto *in_clip = AddNode(&project); + olive::Node::ConnectEdge( + out_clip, + olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + olive::Node::ConnectEdge( + in_clip, olive::NodeInput(trans, olive::TransitionBlock::kInBlockInput)); + + trans->set_offsets_and_length(olive::core::rational(1, 4), + olive::core::rational(3, 4)); + + // The length is always the sum of both offsets + EXPECT_EQ(trans->length(), olive::core::rational(1)); + EXPECT_EQ(trans->offset_center(), olive::core::rational(1, 4)); + + // The offset arguments are named from the adjoining clips' perspective + // (OTIO convention): the in_offset argument describes the overlap with + // the previous clip, which is the transition's out side + EXPECT_EQ(trans->out_offset(), olive::core::rational(1, 4)); + EXPECT_EQ(trans->in_offset(), olive::core::rational(3, 4)); +} + +TEST(TransitionBlock, ConnectAndDisconnectUpdateLinkedClips) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *trans = AddNode(&project); + auto *clip = AddNode(&project); + + olive::Node::ConnectEdge( + clip, olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + EXPECT_EQ(trans->connected_out_block(), clip); + EXPECT_EQ(clip->out_transition(), trans); + + olive::Node::DisconnectEdge( + clip, olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + EXPECT_EQ(trans->connected_out_block(), nullptr); + EXPECT_EQ(clip->out_transition(), nullptr); + + // Connecting a node that is not a clip leaves the linked block null + auto *not_a_clip = AddNode(&project); + olive::Node::ConnectEdge( + not_a_clip, + olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); + EXPECT_EQ(trans->connected_out_block(), nullptr); + olive::Node::DisconnectEdge( + not_a_clip, + olive::NodeInput(trans, olive::TransitionBlock::kOutBlockInput)); +} + +TEST(TransitionBlock, TextureValuePushesJobWithTransitionProgress) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(2)); + + olive::TexturePtr out_tex = MakeDummyTexture(); + olive::TexturePtr in_tex = MakeDummyTexture(); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kOutBlockInput, + olive::NodeValue(olive::NodeValue::kTexture, out_tex)); + row.insert(olive::TransitionBlock::kInBlockInput, + olive::NodeValue(olive::NodeValue::kTexture, in_tex)); + row.insert(olive::TransitionBlock::kCurveInput, + olive::NodeValue(olive::NodeValue::kCombo, 0)); + + // Half-way through a two second transition + const olive::NodeGlobals globals( + olive::VideoParams(64, 64, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount), + olive::core::AudioParams(), + olive::TimeRange(olive::core::rational(1), olive::core::rational(2)), + olive::LoopMode::kLoopModeOff); + + olive::NodeValueTable table; + trans.Value(row, globals, &table); + + olive::TexturePtr job_tex = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(job_tex); + ASSERT_TRUE(job_tex->IsJob()); + EXPECT_EQ(job_tex->params().width(), 64); + + auto *job = dynamic_cast(job_tex->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(olive::TransitionBlock::kOutBlockInput).toTexture(), + out_tex); + EXPECT_EQ(job->Get(olive::TransitionBlock::kInBlockInput).toTexture(), + in_tex); + EXPECT_EQ(job->Get(olive::TransitionBlock::kCurveInput).toInt(), 0); + + // Without connected clips the in/out offsets are zero, so only the total + // transition progress is meaningful + EXPECT_DOUBLE_EQ(job->Get(QStringLiteral("ove_tprog_all")).toDouble(), + 0.5); + EXPECT_DOUBLE_EQ(job->Get(QStringLiteral("ove_tprog_out")).toDouble(), + 0.0); + EXPECT_DOUBLE_EQ(job->Get(QStringLiteral("ove_tprog_in")).toDouble(), + 0.0); +} + +TEST(TransitionBlock, TextureValueInsertsNullTextureForMissingSide) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(2)); + + olive::TexturePtr in_tex = MakeDummyTexture(); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kInBlockInput, + olive::NodeValue(olive::NodeValue::kTexture, in_tex)); + row.insert(olive::TransitionBlock::kCurveInput, + olive::NodeValue(olive::NodeValue::kCombo, 0)); + + olive::NodeValueTable table; + trans.Value(row, olive::NodeGlobals(), &table); + + olive::TexturePtr job_tex = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(job_tex); + ASSERT_TRUE(job_tex->IsJob()); + + auto *job = dynamic_cast(job_tex->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(olive::TransitionBlock::kInBlockInput).toTexture(), + in_tex); + + // The missing "from" side is still inserted, as a null texture + const olive::NodeValue out_side = + job->Get(olive::TransitionBlock::kOutBlockInput); + EXPECT_EQ(out_side.type(), olive::NodeValue::kTexture); + EXPECT_TRUE(out_side.toTexture() == nullptr); +} + +TEST(TransitionBlock, DipToColorValueInsertsColorIntoJob) +{ + olive::DipToColorTransition trans; + trans.set_length_and_media_out(olive::core::rational(2)); + + olive::TexturePtr out_tex = MakeDummyTexture(); + olive::TexturePtr in_tex = MakeDummyTexture(); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kOutBlockInput, + olive::NodeValue(olive::NodeValue::kTexture, out_tex)); + row.insert(olive::TransitionBlock::kInBlockInput, + olive::NodeValue(olive::NodeValue::kTexture, in_tex)); + row.insert(olive::TransitionBlock::kCurveInput, + olive::NodeValue(olive::NodeValue::kCombo, 0)); + row.insert(olive::DipToColorTransition::kColorInput, + olive::NodeValue(olive::NodeValue::kColor, + QVariant::fromValue(olive::core::Color( + 0.25f, 0.5f, 0.75f, 1.0f)))); + + olive::NodeValueTable table; + trans.Value(row, olive::NodeGlobals(), &table); + + olive::TexturePtr job_tex = + table.Get(olive::NodeValue::kTexture).toTexture(); + ASSERT_TRUE(job_tex); + ASSERT_TRUE(job_tex->IsJob()); + + auto *job = dynamic_cast(job_tex->job()); + ASSERT_TRUE(job); + const olive::core::Color color = + job->Get(olive::DipToColorTransition::kColorInput).toColor(); + 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(), 1.0f); +} + +TEST(TransitionBlock, AudioValueMixesLinearCrossfade) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(1)); + + // Half a second at 48 kHz + const size_t sample_count = 24000; + const olive::core::SampleBuffer from = + MakeConstantBuffer(1.0f, sample_count); + const olive::core::SampleBuffer to = MakeConstantBuffer(0.5f, sample_count); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kOutBlockInput, SampleValue(from)); + row.insert(olive::TransitionBlock::kInBlockInput, SampleValue(to)); + + olive::NodeValueTable table; + trans.Value(row, + AudioGlobals(olive::core::rational(0), + olive::core::rational(1, 2)), + &table); + + const olive::NodeValue out_val = table.Get(olive::NodeValue::kSamples); + ASSERT_EQ(out_val.type(), olive::NodeValue::kSamples); + const olive::core::SampleBuffer out = out_val.toSamples(); + ASSERT_TRUE(out.is_allocated()); + EXPECT_EQ(out.sample_count(), sample_count); + + // Sample 0 is fully "from", sample 12000 is 25% through the transition + for (int channel = 0; channel < 2; channel++) { + EXPECT_FLOAT_EQ(out.data(channel)[0], 1.0f); + EXPECT_FLOAT_EQ(out.data(channel)[12000], 0.875f); + } +} + +TEST(TransitionBlock, AudioValueMixesExponentialCrossfade) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(1)); + trans.SetStandardValue(olive::TransitionBlock::kCurveInput, 1); + + const size_t sample_count = 24000; + const olive::core::SampleBuffer from = + MakeConstantBuffer(1.0f, sample_count); + const olive::core::SampleBuffer to = MakeConstantBuffer(0.5f, sample_count); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kOutBlockInput, SampleValue(from)); + row.insert(olive::TransitionBlock::kInBlockInput, SampleValue(to)); + + olive::NodeValueTable table; + trans.Value(row, + AudioGlobals(olive::core::rational(0), + olive::core::rational(1, 2)), + &table); + + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + + // The exponential curve squares the linear progress: at 25% the mix is + // 1.0 * 0.75^2 + 0.5 * 0.25^2 + EXPECT_FLOAT_EQ(out.data(0)[0], 1.0f); + EXPECT_FLOAT_EQ(out.data(0)[12000], 0.59375f); +} + +TEST(TransitionBlock, AudioValueMixesLogarithmicCrossfade) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(1)); + trans.SetStandardValue(olive::TransitionBlock::kCurveInput, 2); + + const size_t sample_count = 24000; + const olive::core::SampleBuffer from = + MakeConstantBuffer(1.0f, sample_count); + const olive::core::SampleBuffer to = MakeConstantBuffer(0.5f, sample_count); + + olive::NodeValueRow row; + row.insert(olive::TransitionBlock::kOutBlockInput, SampleValue(from)); + row.insert(olive::TransitionBlock::kInBlockInput, SampleValue(to)); + + olive::NodeValueTable table; + trans.Value(row, + AudioGlobals(olive::core::rational(0), + olive::core::rational(1, 2)), + &table); + + const olive::core::SampleBuffer out = + table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(out.is_allocated()); + + // The logarithmic curve square-roots the linear progress: at 25% the mix + // is 1.0 * sqrt(0.75) + 0.5 * sqrt(0.25) + EXPECT_FLOAT_EQ(out.data(0)[0], 1.0f); + EXPECT_NEAR(out.data(0)[12000], std::sqrt(0.75) + 0.25, 1e-6); +} + +TEST(TransitionBlock, AudioValueWithSingleSideFades) +{ + // Only a "from" buffer: fade out + olive::CrossDissolveTransition fade_out; + fade_out.set_length_and_media_out(olive::core::rational(1)); + + const size_t sample_count = 24000; + olive::NodeValueRow out_row; + out_row.insert(olive::TransitionBlock::kOutBlockInput, + SampleValue(MakeConstantBuffer(1.0f, sample_count))); + + olive::NodeValueTable out_table; + fade_out.Value(out_row, + AudioGlobals(olive::core::rational(0), + olive::core::rational(1, 2)), + &out_table); + + const olive::core::SampleBuffer faded_out = + out_table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(faded_out.is_allocated()); + EXPECT_FLOAT_EQ(faded_out.data(0)[0], 1.0f); + EXPECT_FLOAT_EQ(faded_out.data(0)[12000], 0.75f); + + // Only a "to" buffer: fade in + olive::CrossDissolveTransition fade_in; + fade_in.set_length_and_media_out(olive::core::rational(1)); + + olive::NodeValueRow in_row; + in_row.insert(olive::TransitionBlock::kInBlockInput, + SampleValue(MakeConstantBuffer(0.5f, sample_count))); + + olive::NodeValueTable in_table; + fade_in.Value(in_row, + AudioGlobals(olive::core::rational(0), + olive::core::rational(1, 2)), + &in_table); + + const olive::core::SampleBuffer faded_in = + in_table.Get(olive::NodeValue::kSamples).toSamples(); + ASSERT_TRUE(faded_in.is_allocated()); + EXPECT_FLOAT_EQ(faded_in.data(0)[0], 0.0f); + EXPECT_FLOAT_EQ(faded_in.data(0)[12000], 0.125f); +} + +TEST(TransitionBlock, ValueWithNoInputsPushesNothing) +{ + olive::CrossDissolveTransition trans; + trans.set_length_and_media_out(olive::core::rational(1)); + + olive::NodeValueTable table; + trans.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_EQ(table.Count(), 0); +} + +// ----------------------------------------------------------------------------- +// SubtitleBlock +// ----------------------------------------------------------------------------- + +TEST(SubtitleBlock, MetadataIsCorrect) +{ + olive::SubtitleBlock node; + + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.subtitle")); + EXPECT_EQ(node.Name(), QStringLiteral("Subtitle")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryTimeline)); +} + +TEST(SubtitleBlock, NameFollowsText) +{ + olive::SubtitleBlock node; + + EXPECT_TRUE(node.GetText().isEmpty()); + EXPECT_EQ(node.Name(), QStringLiteral("Subtitle")); + + node.SetText(QStringLiteral("Hello World")); + EXPECT_EQ(node.GetText(), QStringLiteral("Hello World")); + EXPECT_EQ(node.Name(), QStringLiteral("Hello World")); + + node.SetText(QString()); + EXPECT_EQ(node.Name(), QStringLiteral("Subtitle")); +} + +TEST(SubtitleBlock, TextInputFlagsAndHiddenClipInputs) +{ + olive::SubtitleBlock node; + + EXPECT_EQ(int(node.GetInputDataType(olive::SubtitleBlock::kTextIn)), + int(olive::NodeValue::kText)); + + // The text is edited inline: neither connectable nor keyframable + EXPECT_FALSE(node.IsInputConnectable(olive::SubtitleBlock::kTextIn)); + EXPECT_FALSE(node.IsInputKeyframable(olive::SubtitleBlock::kTextIn)); + + // The inherited clip inputs are meaningless for a subtitle and hidden + EXPECT_TRUE(node.IsInputHidden(olive::ClipBlock::kBufferIn)); + EXPECT_TRUE(node.IsInputHidden(olive::Block::kLengthInput)); + EXPECT_TRUE(node.IsInputHidden(olive::ClipBlock::kMediaInInput)); + EXPECT_TRUE(node.IsInputHidden(olive::ClipBlock::kSpeedInput)); + EXPECT_TRUE(node.IsInputHidden(olive::ClipBlock::kReverseInput)); + EXPECT_TRUE(node.IsInputHidden(olive::ClipBlock::kMaintainAudioPitchInput)); + + // Blocks hide from the param view by default, subtitles re-enable it + EXPECT_FALSE(node.GetFlags() & olive::Node::kDontShowInParamView); +} + +TEST(SubtitleBlock, RetranslateSetsInputName) +{ + olive::SubtitleBlock node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::SubtitleBlock::kTextIn), + QStringLiteral("Text")); +} + +// ----------------------------------------------------------------------------- +// NodeTraverser: NodeGlobals construction and value caching +// ----------------------------------------------------------------------------- + +TEST(NodeTraverser, GenerateTablePassesCacheParamsToNodeGlobals) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *probe = AddNode(&project); + + olive::NodeTraverser traverser; + traverser.SetCacheVideoParams( + olive::VideoParams(1920, 1080, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + traverser.SetCacheAudioParams( + olive::core::AudioParams(44100, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P)); + + traverser.GenerateTable(probe, + olive::TimeRange(olive::core::rational(0), + olive::core::rational(1, 30))); + + EXPECT_EQ(probe->last_vparams().width(), 1920); + EXPECT_EQ(probe->last_vparams().height(), 1080); + EXPECT_EQ(probe->last_aparams().sample_rate(), 44100); +} + +TEST(NodeTraverser, GenerateTableCachesResultsPerNodeAndRange) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TrigonometryNode::kXIn, 1.0); + + const olive::TimeRange range(olive::core::rational(0), + olive::core::rational(1, 30)); + + olive::NodeTraverser traverser; + const double first = + traverser.GenerateTable(node, range) + .Get(olive::NodeValue::kFloat) + .toDouble(); + EXPECT_NEAR(first, std::sin(1.0), 1e-12); + + // The same traverser returns the cached table for the same node and + // range, even after the node's inputs change + node->SetStandardValue(olive::TrigonometryNode::kXIn, 0.0); + const double cached = + traverser.GenerateTable(node, range) + .Get(olive::NodeValue::kFloat) + .toDouble(); + EXPECT_DOUBLE_EQ(cached, first); + + // A fresh traverser recomputes + olive::NodeTraverser fresh; + const double updated = + fresh.GenerateTable(node, range) + .Get(olive::NodeValue::kFloat) + .toDouble(); + EXPECT_DOUBLE_EQ(updated, 0.0); +}