diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 18ec4a470..778aef436 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -67,6 +67,10 @@ add_executable(olive-gtest node_time_test.cpp node_color_test.cpp plugin_node_test.cpp + sequence_test.cpp + node_generator_test.cpp + render_misc_test.cpp + multicam_serializer_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/multicam_serializer_test.cpp b/tests/gtest/multicam_serializer_test.cpp new file mode 100644 index 000000000..5313979da --- /dev/null +++ b/tests/gtest/multicam_serializer_test.cpp @@ -0,0 +1,741 @@ +#include + +#include +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/globals.h" +#include "node/input/multicam/multicamnode.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/project.h" +#include "node/project/footage/footagedescription.h" +#include "node/project/sequence/sequence.h" +#include "node/project/serializer/serializer.h" +#include "render/diskmanager.h" + +namespace +{ + +// Project save/load and cache paths go through the DiskManager singleton +void EnsureDiskManager(bool *created) +{ + *created = (olive::DiskManager::instance() == nullptr); + if (*created) { + olive::DiskManager::CreateInstance(); + } +} + +void ReleaseDiskManager(bool created) +{ + if (created) { + olive::DiskManager::DestroyInstance(); + } +} + +} // namespace + +TEST(MultiCamNode, DefaultState) +{ + olive::MultiCamNode node; + + EXPECT_EQ(node.Name(), QStringLiteral("Multi-Cam")); + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.multicam")); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryTimeline)); + + // The "arraystart" property only hints the UI; the sources array itself + // starts empty + EXPECT_EQ(node.GetSourceCount(), 0); + EXPECT_EQ(node.GetCurrentSource(), 0); + + // Sequence type selector stays hidden until a sequence is connected + EXPECT_TRUE(node.GetInputFlags(olive::MultiCamNode::kSequenceTypeInput) & + olive::kInputFlagHidden); +} + +TEST(MultiCamNode, ActiveElementsSelectCurrentSource) +{ + olive::MultiCamNode node; + node.InputArrayResize(olive::MultiCamNode::kSourcesInput, 3); + ASSERT_EQ(node.GetSourceCount(), 3); + + node.SetStandardValue(olive::MultiCamNode::kCurrentInput, 1); + + olive::Node::ActiveElements active = node.GetActiveElementsAtTime( + olive::MultiCamNode::kSourcesInput, olive::TimeRange()); + EXPECT_EQ(active.mode(), olive::Node::ActiveElements::kSpecified); + ASSERT_EQ(active.elements().size(), 1); + EXPECT_EQ(active.elements().front(), 1); + + // Any other input defers to the base implementation + olive::Node::ActiveElements all = node.GetActiveElementsAtTime( + olive::MultiCamNode::kCurrentInput, olive::TimeRange()); + EXPECT_EQ(all.mode(), olive::Node::ActiveElements::kAllElements); +} + +TEST(MultiCamNode, ActiveElementsOutOfRangeAreEmpty) +{ + olive::MultiCamNode node; + node.InputArrayResize(olive::MultiCamNode::kSourcesInput, 3); + + node.SetStandardValue(olive::MultiCamNode::kCurrentInput, 5); + EXPECT_EQ(node.GetActiveElementsAtTime(olive::MultiCamNode::kSourcesInput, + olive::TimeRange()) + .mode(), + olive::Node::ActiveElements::kNoElements); + + node.SetStandardValue(olive::MultiCamNode::kCurrentInput, -1); + EXPECT_EQ(node.GetActiveElementsAtTime(olive::MultiCamNode::kSourcesInput, + olive::TimeRange()) + .mode(), + olive::Node::ActiveElements::kNoElements); +} + +TEST(MultiCamNode, RowsAndColumnsGrowToFitSources) +{ + const struct { + int sources; + int rows; + int cols; + } kCases[] = { { 1, 1, 1 }, { 2, 1, 2 }, { 3, 2, 2 }, { 4, 2, 2 }, + { 5, 2, 3 }, { 6, 2, 3 }, { 9, 3, 3 }, { 12, 3, 4 } }; + + for (const auto &c : kCases) { + int rows = 0, cols = 0; + olive::MultiCamNode::GetRowsAndColumns(c.sources, &rows, &cols); + EXPECT_EQ(rows, c.rows) << "sources=" << c.sources; + EXPECT_EQ(cols, c.cols) << "sources=" << c.sources; + } + + // The grid always fits all sources and stays as square as possible + for (int s = 1; s <= 16; s++) { + int rows = 0, cols = 0; + olive::MultiCamNode::GetRowsAndColumns(s, &rows, &cols); + EXPECT_GE(rows * cols, s); + EXPECT_LE(rows, cols); + } +} + +TEST(MultiCamNode, RowColumnIndexRoundTrip) +{ + int row = -1, col = -1; + olive::MultiCamNode::IndexToRowCols(5, 2, 3, &row, &col); + EXPECT_EQ(row, 1); + EXPECT_EQ(col, 2); + EXPECT_EQ(olive::MultiCamNode::RowsColsToIndex(1, 2, 2, 3), 5); + + const int kRows = 3; + const int kCols = 4; + for (int i = 0; i < kRows * kCols; i++) { + olive::MultiCamNode::IndexToRowCols(i, kRows, kCols, &row, &col); + EXPECT_EQ(olive::MultiCamNode::RowsColsToIndex(row, col, kRows, kCols), + i); + } +} + +TEST(MultiCamNode, RetranslateSetsInputNamesAndComboStrings) +{ + olive::MultiCamNode node; + node.Retranslate(); + + EXPECT_EQ(node.GetInputName(olive::MultiCamNode::kCurrentInput), + QStringLiteral("Current")); + EXPECT_EQ(node.GetInputName(olive::MultiCamNode::kSourcesInput), + QStringLiteral("Sources")); + EXPECT_EQ(node.GetInputName(olive::MultiCamNode::kSequenceInput), + QStringLiteral("Sequence")); + EXPECT_EQ(node.GetInputName(olive::MultiCamNode::kSequenceTypeInput), + QStringLiteral("Sequence Type")); + + EXPECT_EQ( + node.GetComboBoxStrings(olive::MultiCamNode::kSequenceTypeInput), + (QStringList{ QStringLiteral("Video"), QStringLiteral("Audio") })); + + // No sources yet, so no labels + EXPECT_TRUE(node.GetComboBoxStrings(olive::MultiCamNode::kCurrentInput) + .isEmpty()); +} + +TEST(MultiCamNode, IgnoreInputsForRenderingSkipsSequenceInput) +{ + olive::MultiCamNode node; + + EXPECT_TRUE(node.IgnoreInputsForRendering().contains( + olive::MultiCamNode::kSequenceInput)); + EXPECT_FALSE(node.IgnoreInputsForRendering().contains( + olive::MultiCamNode::kSourcesInput)); +} + +TEST(MultiCamNode, ValuePushesFirstSourceArrayElement) +{ + olive::MultiCamNode node; + + olive::NodeValueArray sources; + sources[0] = olive::NodeValue(olive::NodeValue::kText, + QStringLiteral("cam A"), &node); + sources[1] = olive::NodeValue(olive::NodeValue::kText, + QStringLiteral("cam B"), &node); + + olive::NodeValueRow row; + row.insert(olive::MultiCamNode::kSourcesInput, + olive::NodeValue(olive::NodeValue::kNone, + QVariant::fromValue(sources), &node, true)); + + olive::NodeValueTable table; + node.Value(row, olive::NodeGlobals(), &table); + + // Value() forwards the first array element; the traverser filters the + // array down to the active element before Value() is ever called + ASSERT_EQ(table.Count(), 1); + EXPECT_EQ(table.at(0).toString(), QStringLiteral("cam A")); +} + +TEST(MultiCamNode, ValueWithoutSourcesLeavesTableEmpty) +{ + olive::MultiCamNode node; + + olive::NodeValueTable table; + node.Value(olive::NodeValueRow(), olive::NodeGlobals(), &table); + + EXPECT_TRUE(table.isEmpty()); +} + +TEST(MultiCamNode, ConnectedSequenceProvidesTrackSources) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(&project); + auto *node = new olive::MultiCamNode(); + node->setParent(&project); + node->SetSequenceType(olive::Track::kVideo); + + olive::Node::ConnectEdge( + sequence, olive::NodeInput(node, olive::MultiCamNode::kSequenceInput)); + + // Connecting a sequence exposes the type selector + EXPECT_FALSE(node->GetInputFlags(olive::MultiCamNode::kSequenceTypeInput) & + olive::kInputFlagHidden); + + // The sequence has no tracks yet + EXPECT_EQ(node->GetSourceCount(), 0); + + olive::TrackList *video_list = sequence->track_list(olive::Track::kVideo); + + video_list->ArrayAppend(); + auto *track_a = new olive::Track(); + track_a->setParent(&project); + olive::Node::ConnectEdge(track_a, video_list->track_input(0)); + + video_list->ArrayAppend(); + auto *track_b = new olive::Track(); + track_b->setParent(&project); + olive::Node::ConnectEdge(track_b, video_list->track_input(1)); + + // Sources are now pulled from the sequence's track list + ASSERT_EQ(node->GetSourceCount(), 2); + EXPECT_EQ(node->GetConnectedRenderOutput(olive::MultiCamNode::kSourcesInput, + 0), + track_a); + EXPECT_EQ(node->GetConnectedRenderOutput(olive::MultiCamNode::kSourcesInput, + 1), + track_b); + EXPECT_TRUE( + node->IsInputConnectedForRender(olive::MultiCamNode::kSourcesInput, 0)); + EXPECT_TRUE( + node->IsInputConnectedForRender(olive::MultiCamNode::kSourcesInput, 1)); + + // Past the end of the track list the overrides defer to the base class + EXPECT_FALSE( + node->IsInputConnectedForRender(olive::MultiCamNode::kSourcesInput, 2)); + EXPECT_EQ(node->GetConnectedRenderOutput(olive::MultiCamNode::kSourcesInput, + 2), + nullptr); + + node->SetStandardValue(olive::MultiCamNode::kCurrentInput, 1); + olive::Node::ActiveElements active = node->GetActiveElementsAtTime( + olive::MultiCamNode::kSourcesInput, olive::TimeRange()); + EXPECT_EQ(active.mode(), olive::Node::ActiveElements::kSpecified); + ASSERT_EQ(active.elements().size(), 1); + EXPECT_EQ(active.elements().front(), 1); + + // Retranslate names each angle after its track + node->Retranslate(); + EXPECT_EQ(node->GetComboBoxStrings(olive::MultiCamNode::kCurrentInput), + (QStringList{ QStringLiteral("1: Video Track 0"), + QStringLiteral("2: Video Track 1") })); + + // Disconnecting hides the type selector and falls back to the sources array + olive::Node::DisconnectEdge( + sequence, olive::NodeInput(node, olive::MultiCamNode::kSequenceInput)); + EXPECT_TRUE(node->GetInputFlags(olive::MultiCamNode::kSequenceTypeInput) & + olive::kInputFlagHidden); + // With nothing appended to the sources array, it falls back to zero + EXPECT_EQ(node->GetSourceCount(), 0); +} + +TEST(MultiCamNode, SequenceTypeSelectsTrackList) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *sequence = new olive::Sequence(); + sequence->setParent(&project); + auto *node = new olive::MultiCamNode(); + node->setParent(&project); + + olive::Node::ConnectEdge( + sequence, olive::NodeInput(node, olive::MultiCamNode::kSequenceInput)); + + node->SetSequenceType(olive::Track::kAudio); + EXPECT_EQ(node->GetSourceCount(), 0); + + olive::TrackList *audio_list = sequence->track_list(olive::Track::kAudio); + audio_list->ArrayAppend(); + auto *track = new olive::Track(); + track->setParent(&project); + olive::Node::ConnectEdge(track, audio_list->track_input(0)); + + EXPECT_EQ(node->GetSourceCount(), 1); + EXPECT_EQ(node->GetConnectedRenderOutput(olive::MultiCamNode::kSourcesInput, + 0), + track); + + // Switching the type swaps which track list feeds the sources + node->SetSequenceType(olive::Track::kVideo); + EXPECT_EQ(node->GetSourceCount(), 0); +} + +TEST(FootageDescription, DefaultStateIsInvalid) +{ + olive::FootageDescription desc; + + EXPECT_TRUE(desc.decoder().isEmpty()); + EXPECT_EQ(desc.GetStreamCount(), 0); + EXPECT_TRUE(desc.GetVideoStreams().isEmpty()); + EXPECT_TRUE(desc.GetAudioStreams().isEmpty()); + EXPECT_TRUE(desc.GetSubtitleStreams().isEmpty()); + EXPECT_FALSE(desc.HasSourceStartTime()); + EXPECT_FALSE(desc.IsValid()); +} + +TEST(FootageDescription, ValidityRequiresDecoderAndStream) +{ + olive::VideoParams video(640, 480, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video.set_stream_index(0); + + // A stream without a decoder name is not enough + olive::FootageDescription no_decoder; + no_decoder.AddVideoStream(video); + EXPECT_FALSE(no_decoder.IsValid()); + + // A decoder without any stream is not enough either + olive::FootageDescription desc(QStringLiteral("fakedecoder")); + EXPECT_FALSE(desc.IsValid()); + + desc.AddVideoStream(video); + EXPECT_TRUE(desc.IsValid()); +} + +TEST(FootageDescription, StreamTypeLookup) +{ + olive::FootageDescription desc(QStringLiteral("fakedecoder")); + + olive::VideoParams video(1920, 1080, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video.set_stream_index(0); + desc.AddVideoStream(video); + + olive::core::AudioParams audio(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + audio.set_stream_index(1); + desc.AddAudioStream(audio); + + olive::SubtitleParams subs; + subs.set_stream_index(2); + subs.push_back(olive::Subtitle(olive::TimeRange(olive::rational(0), + olive::rational(3)), + QStringLiteral("subtitle text"))); + desc.AddSubtitleStream(subs); + + desc.SetStreamCount(3); + + EXPECT_EQ(desc.decoder(), QStringLiteral("fakedecoder")); + EXPECT_EQ(desc.GetStreamCount(), 3); + + EXPECT_TRUE(desc.StreamIsVideo(0)); + EXPECT_FALSE(desc.StreamIsVideo(1)); + EXPECT_TRUE(desc.StreamIsAudio(1)); + EXPECT_FALSE(desc.StreamIsAudio(2)); + EXPECT_TRUE(desc.StreamIsSubtitle(2)); + EXPECT_FALSE(desc.StreamIsSubtitle(0)); + + EXPECT_TRUE(desc.HasStreamIndex(0)); + EXPECT_TRUE(desc.HasStreamIndex(1)); + EXPECT_TRUE(desc.HasStreamIndex(2)); + EXPECT_FALSE(desc.HasStreamIndex(3)); + + EXPECT_EQ(desc.GetTypeOfStream(0), olive::Track::kVideo); + EXPECT_EQ(desc.GetTypeOfStream(1), olive::Track::kAudio); + EXPECT_EQ(desc.GetTypeOfStream(2), olive::Track::kSubtitle); + EXPECT_EQ(desc.GetTypeOfStream(99), olive::Track::kNone); + + ASSERT_EQ(desc.GetVideoStreams().size(), 1); + ASSERT_EQ(desc.GetAudioStreams().size(), 1); + ASSERT_EQ(desc.GetSubtitleStreams().size(), 1); +} + +TEST(FootageDescription, SaveLoadRoundTrip) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = + QDir(dir.path()).filePath(QStringLiteral("streamcache.xml")); + + olive::FootageDescription desc(QStringLiteral("fakedecoder")); + + olive::VideoParams video(1920, 1080, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video.set_stream_index(0); + video.set_duration(48); + desc.AddVideoStream(video); + + olive::core::AudioParams audio(48000, olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + audio.set_stream_index(1); + audio.set_duration(96000); + desc.AddAudioStream(audio); + + olive::SubtitleParams subs; + subs.set_stream_index(2); + subs.push_back(olive::Subtitle(olive::TimeRange(olive::rational(0), + olive::rational(3)), + QStringLiteral("subtitle text"))); + desc.AddSubtitleStream(subs); + + desc.SetStreamCount(3); + + ASSERT_TRUE(desc.Save(path)); + + olive::FootageDescription loaded; + ASSERT_TRUE(loaded.Load(path)); + + EXPECT_TRUE(loaded.IsValid()); + EXPECT_EQ(loaded.decoder(), QStringLiteral("fakedecoder")); + EXPECT_EQ(loaded.GetStreamCount(), 3); + + ASSERT_EQ(loaded.GetVideoStreams().size(), 1); + const olive::VideoParams &loaded_video = loaded.GetVideoStreams().first(); + EXPECT_EQ(loaded_video.width(), 1920); + EXPECT_EQ(loaded_video.height(), 1080); + EXPECT_EQ(loaded_video.stream_index(), 0); + EXPECT_EQ(loaded_video.duration(), video.duration()); + EXPECT_EQ(loaded_video.time_base(), video.time_base()); + + ASSERT_EQ(loaded.GetAudioStreams().size(), 1); + const olive::core::AudioParams &loaded_audio = + loaded.GetAudioStreams().first(); + EXPECT_EQ(loaded_audio.sample_rate(), 48000); + EXPECT_EQ(loaded_audio.channel_layout(), olive::core::kChannelLayoutStereo); + EXPECT_EQ(loaded_audio.stream_index(), 1); + EXPECT_EQ(loaded_audio.duration(), audio.duration()); + + ASSERT_EQ(loaded.GetSubtitleStreams().size(), 1); + const olive::SubtitleParams &loaded_subs = + loaded.GetSubtitleStreams().first(); + EXPECT_EQ(loaded_subs.stream_index(), 2); + ASSERT_EQ(loaded_subs.size(), 1); + EXPECT_EQ(loaded_subs.front().text(), QStringLiteral("subtitle text")); + EXPECT_EQ(loaded_subs.front().time().out(), olive::rational(3)); +} + +TEST(FootageDescription, LoadMissingFileFailsAndResetsState) +{ + olive::FootageDescription desc(QStringLiteral("stale")); + olive::VideoParams video(640, 480, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video.set_stream_index(0); + desc.AddVideoStream(video); + ASSERT_TRUE(desc.IsValid()); + + EXPECT_FALSE( + desc.Load(QStringLiteral("/definitely/nonexistent/cache.xml"))); + + // A failed load must not leave stale streams behind + EXPECT_TRUE(desc.decoder().isEmpty()); + EXPECT_TRUE(desc.GetVideoStreams().isEmpty()); + EXPECT_FALSE(desc.IsValid()); +} + +TEST(FootageDescription, LoadRejectsMismatchedVersion) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = + QDir(dir.path()).filePath(QStringLiteral("legacy-cache.xml")); + + // No version attribute: treated as the original unversioned cache format, + // which is always discarded so the footage can be re-probed + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("fakedecoder"); + file.close(); + + olive::FootageDescription desc(QStringLiteral("stale")); + olive::VideoParams video(640, 480, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + video.set_stream_index(0); + desc.AddVideoStream(video); + + EXPECT_FALSE(desc.Load(path)); + EXPECT_TRUE(desc.decoder().isEmpty()); + EXPECT_FALSE(desc.IsValid()); +} + +TEST(ProjectSerializer, FileRoundTripPreservesMultiCamNode) +{ + olive::ColorManager::SetUpDefaultConfig(); + bool created_disk_manager = false; + EnsureDiskManager(&created_disk_manager); + olive::NodeFactory::Initialize(); + olive::ProjectSerializer::Initialize(); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString filename = + QDir(dir.path()).filePath(QStringLiteral("multicam.ove")); + + olive::Project project; + project.Initialize(); + auto *node = new olive::MultiCamNode(); + node->SetLabel(QStringLiteral("Angles")); + node->SetStandardValue(olive::MultiCamNode::kCurrentInput, 2); + node->setParent(&project); + + olive::ProjectSerializer::Result save_result = + olive::ProjectSerializer::Save(olive::ProjectSerializer::SaveData( + olive::ProjectSerializer::kProject, + &project, filename), + false); + ASSERT_EQ(save_result.code(), olive::ProjectSerializer::kSuccess); + ASSERT_TRUE(QFile::exists(filename)); + + // An uncompressed project is plain XML + QFile raw(filename); + ASSERT_TRUE(raw.open(QFile::ReadOnly)); + EXPECT_TRUE(raw.read(5).startsWith("(n))) { + break; + } + } + ASSERT_NE(loaded_node, nullptr); + EXPECT_EQ(loaded_node->GetLabel(), QStringLiteral("Angles")); + EXPECT_EQ(loaded_node->GetCurrentSource(), 2); + + olive::ProjectSerializer::Destroy(); + olive::NodeFactory::Destroy(); + ReleaseDiskManager(created_disk_manager); +} + +TEST(ProjectSerializer, CompressedFileRoundTrip) +{ + olive::ColorManager::SetUpDefaultConfig(); + bool created_disk_manager = false; + EnsureDiskManager(&created_disk_manager); + olive::NodeFactory::Initialize(); + olive::ProjectSerializer::Initialize(); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString filename = + QDir(dir.path()).filePath(QStringLiteral("compressed.ove")); + + olive::Project project; + project.Initialize(); + auto *node = new olive::MultiCamNode(); + node->SetLabel(QStringLiteral("Compressed")); + node->setParent(&project); + + olive::ProjectSerializer::Result save_result = + olive::ProjectSerializer::Save(olive::ProjectSerializer::SaveData( + olive::ProjectSerializer::kProject, + &project, filename), + true); + ASSERT_EQ(save_result.code(), olive::ProjectSerializer::kSuccess); + + // Compressed projects are marked with the OVEC signature + QFile raw(filename); + ASSERT_TRUE(raw.open(QFile::ReadOnly)); + EXPECT_EQ(raw.read(4), QByteArray("OVEC", 4)); + raw.close(); + + olive::Project loaded_project; + olive::ProjectSerializer::Result load_result = + olive::ProjectSerializer::Load(&loaded_project, filename, + olive::ProjectSerializer::kProject); + ASSERT_EQ(load_result.code(), olive::ProjectSerializer::kSuccess); + + bool found = false; + foreach (olive::Node *n, loaded_project.nodes()) { + if (dynamic_cast(n)) { + found = true; + EXPECT_EQ(n->GetLabel(), QStringLiteral("Compressed")); + break; + } + } + EXPECT_TRUE(found); + + olive::ProjectSerializer::Destroy(); + olive::NodeFactory::Destroy(); + ReleaseDiskManager(created_disk_manager); +} + +TEST(ProjectSerializer, LoadNonexistentFileFails) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + &project, QStringLiteral("/definitely/nonexistent/project.ove"), + olive::ProjectSerializer::kProject); + + EXPECT_EQ(result.code(), olive::ProjectSerializer::kFileError); + EXPECT_FALSE(result.GetDetails().isEmpty()); +} + +TEST(ProjectSerializer, LoadGarbageXmlReportsUnknownVersion) +{ + olive::ColorManager::SetUpDefaultConfig(); + + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString filename = + QDir(dir.path()).filePath(QStringLiteral("garbage.ove")); + QFile file(filename); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("this is not a project file"); + file.close(); + + olive::Project project; + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + &project, filename, olive::ProjectSerializer::kProject); + + EXPECT_EQ(result.code(), olive::ProjectSerializer::kUnknownVersion); +} + +TEST(ProjectSerializer, LoadOlderVersionReportsTooOld) +{ + olive::ProjectSerializer::Initialize(); + + // 190219 predates every registered serializer + QXmlStreamReader reader( + QStringLiteral("")); + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + nullptr, &reader, olive::ProjectSerializer::kOnlyNodes); + + EXPECT_EQ(result.code(), olive::ProjectSerializer::kProjectTooOld); + + olive::ProjectSerializer::Destroy(); +} + +TEST(ProjectSerializer, LoadNewerVersionReportsTooNew) +{ + olive::ProjectSerializer::Initialize(); + + QXmlStreamReader reader( + QStringLiteral("")); + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + nullptr, &reader, olive::ProjectSerializer::kOnlyNodes); + + EXPECT_EQ(result.code(), olive::ProjectSerializer::kProjectTooNew); + + olive::ProjectSerializer::Destroy(); +} + +TEST(ProjectSerializer, LoadWithoutVersionReportsUnknown) +{ + // A project element without a version attribute + QXmlStreamReader no_version(QStringLiteral("")); + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + nullptr, &no_version, olive::ProjectSerializer::kOnlyNodes); + EXPECT_EQ(result.code(), olive::ProjectSerializer::kUnknownVersion); + + // No recognizable root element at all + QXmlStreamReader wrong_root( + QStringLiteral("")); + result = olive::ProjectSerializer::Load( + nullptr, &wrong_root, olive::ProjectSerializer::kOnlyNodes); + EXPECT_EQ(result.code(), olive::ProjectSerializer::kUnknownVersion); +} + +TEST(ProjectSerializer, CheckCompressedIDDetectsSignature) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString compressed = + QDir(dir.path()).filePath(QStringLiteral("compressed.ove")); + QFile f(compressed); + ASSERT_TRUE(f.open(QFile::WriteOnly)); + f.write("OVEC"); + f.write("payload"); + f.close(); + + QFile check(compressed); + ASSERT_TRUE(check.open(QFile::ReadOnly)); + EXPECT_TRUE(olive::ProjectSerializer::CheckCompressedID(&check)); + check.close(); + + const QString plain = + QDir(dir.path()).filePath(QStringLiteral("plain.ove")); + QFile p(plain); + ASSERT_TRUE(p.open(QFile::WriteOnly)); + p.write(""); + p.close(); + + QFile check_plain(plain); + ASSERT_TRUE(check_plain.open(QFile::ReadOnly)); + EXPECT_FALSE(olive::ProjectSerializer::CheckCompressedID(&check_plain)); + check_plain.close(); +} + +TEST(ProjectSerializer, SaveToInvalidDirectoryFails) +{ + olive::ColorManager::SetUpDefaultConfig(); + bool created_disk_manager = false; + EnsureDiskManager(&created_disk_manager); + olive::ProjectSerializer::Initialize(); + + olive::Project project; + project.Initialize(); + + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Save( + olive::ProjectSerializer::SaveData( + olive::ProjectSerializer::kProject, &project, + QStringLiteral("/definitely/nonexistent/project.ove")), + false); + + EXPECT_EQ(result.code(), olive::ProjectSerializer::kFileError); + EXPECT_FALSE(result.GetDetails().isEmpty()); + + olive::ProjectSerializer::Destroy(); + ReleaseDiskManager(created_disk_manager); +} diff --git a/tests/gtest/node_generator_test.cpp b/tests/gtest/node_generator_test.cpp new file mode 100644 index 000000000..11644cba8 --- /dev/null +++ b/tests/gtest/node_generator_test.cpp @@ -0,0 +1,1158 @@ +#include + +#include +#include +#include +#include + +#include "node/color/colormanager/colormanager.h" +#include "node/generator/matrix/matrix.h" +#include "node/generator/noise/noise.h" +#include "node/generator/polygon/polygon.h" +#include "node/generator/shape/shapenode.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv3.h" +#include "node/math/merge/merge.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/texture.h" +#include "widget/slider/floatslider.h" + +namespace +{ + +// Node that pushes a fixed dummy texture, used to feed the base input of +// merge-capable generators without any renderer. +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.constant_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)); +} + +olive::VideoParams TestVideoParams() +{ + return olive::VideoParams(320, 240, olive::core::PixelFormat::U8, 4); +} + +// 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) +{ + olive::NodeTraverser traverser; + return traverser.GenerateTable(node, FirstFrame()); +} + +olive::NodeValueTable GenerateTable(const olive::Node *node, + const olive::VideoParams &vparams, + const olive::TimeRange &range) +{ + olive::NodeTraverser traverser; + traverser.SetCacheVideoParams(vparams); + return traverser.GenerateTable(node, range); +} + +olive::NodeValueTable GenerateTable(const olive::Node *node, + const olive::VideoParams &vparams) +{ + return GenerateTable(node, vparams, FirstFrame()); +} + +olive::TexturePtr GetOutputTexture(const olive::NodeValueTable &table) +{ + return table.Get(olive::NodeValue::kTexture).toTexture(); +} + +} // namespace + +TEST(MatrixGenerator, MetadataIsCorrect) +{ + olive::MatrixGenerator node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.ortho")); + EXPECT_EQ(node.Name(), QStringLiteral("Orthographic Matrix")); + EXPECT_EQ(node.ShortName(), QStringLiteral("Ortho")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryMath)); +} + +TEST(MatrixGenerator, InputDefaultsAndProperties) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + ASSERT_TRUE(node->HasInputWithID(olive::MatrixGenerator::kPositionInput)); + ASSERT_TRUE(node->HasInputWithID(olive::MatrixGenerator::kRotationInput)); + ASSERT_TRUE(node->HasInputWithID(olive::MatrixGenerator::kScaleInput)); + ASSERT_TRUE( + node->HasInputWithID(olive::MatrixGenerator::kUniformScaleInput)); + ASSERT_TRUE(node->HasInputWithID(olive::MatrixGenerator::kAnchorInput)); + + EXPECT_EQ(int(node->GetInputDataType(olive::MatrixGenerator::kPositionInput)), + int(olive::NodeValue::kVec2)); + EXPECT_EQ(int(node->GetInputDataType(olive::MatrixGenerator::kRotationInput)), + int(olive::NodeValue::kFloat)); + EXPECT_EQ(int(node->GetInputDataType(olive::MatrixGenerator::kScaleInput)), + int(olive::NodeValue::kVec2)); + EXPECT_EQ( + int(node->GetInputDataType(olive::MatrixGenerator::kUniformScaleInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_EQ(int(node->GetInputDataType(olive::MatrixGenerator::kAnchorInput)), + int(olive::NodeValue::kVec2)); + + EXPECT_EQ(node->GetStandardValue(olive::MatrixGenerator::kPositionInput) + .value(), + QVector2D(0.0f, 0.0f)); + EXPECT_EQ(node->GetStandardValue(olive::MatrixGenerator::kRotationInput) + .toDouble(), + 0.0); + EXPECT_EQ( + node->GetStandardValue(olive::MatrixGenerator::kScaleInput).value(), + QVector2D(1.0f, 1.0f)); + EXPECT_TRUE(node->GetStandardValue(olive::MatrixGenerator::kUniformScaleInput) + .toBool()); + EXPECT_EQ(node->GetStandardValue(olive::MatrixGenerator::kAnchorInput) + .value(), + QVector2D(0.0f, 0.0f)); + + // Scale slider is percentage-based, floored at zero, and starts with its + // second track disabled because uniform scale defaults to on + EXPECT_EQ(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); + EXPECT_EQ(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("min")) + .value(), + QVector2D(0.0f, 0.0f)); + EXPECT_TRUE(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("disable1")) + .toBool()); + + // Uniform scale is a UI toggle, not a renderable parameter + EXPECT_FALSE(node->IsInputConnectable( + olive::MatrixGenerator::kUniformScaleInput)); + EXPECT_FALSE(node->IsInputKeyframable( + olive::MatrixGenerator::kUniformScaleInput)); + EXPECT_TRUE( + node->IsInputConnectable(olive::MatrixGenerator::kPositionInput)); + EXPECT_TRUE( + node->IsInputKeyframable(olive::MatrixGenerator::kPositionInput)); +} + +TEST(MatrixGenerator, RetranslateSetsInputNames) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::MatrixGenerator::kPositionInput), + QStringLiteral("Position")); + EXPECT_EQ(node->GetInputName(olive::MatrixGenerator::kRotationInput), + QStringLiteral("Rotation")); + EXPECT_EQ(node->GetInputName(olive::MatrixGenerator::kScaleInput), + QStringLiteral("Scale")); + EXPECT_EQ(node->GetInputName(olive::MatrixGenerator::kUniformScaleInput), + QStringLiteral("Uniform Scale")); + EXPECT_EQ(node->GetInputName(olive::MatrixGenerator::kAnchorInput), + QStringLiteral("Anchor Point")); +} + +TEST(MatrixGenerator, UniformScaleTogglesScaleSecondTrack) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + EXPECT_TRUE(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("disable1")) + .toBool()); + + node->SetStandardValue(olive::MatrixGenerator::kUniformScaleInput, false); + EXPECT_FALSE(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("disable1")) + .toBool()); + + node->SetStandardValue(olive::MatrixGenerator::kUniformScaleInput, true); + EXPECT_TRUE(node->GetInputProperty(olive::MatrixGenerator::kScaleInput, + QStringLiteral("disable1")) + .toBool()); +} + +TEST(MatrixGenerator, DefaultValueIsIdentityMatrix) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + olive::NodeValueTable table = GenerateTable(node); + olive::NodeValue value = table.Get(olive::NodeValue::kMatrix); + ASSERT_EQ(int(value.type()), int(olive::NodeValue::kMatrix)); + EXPECT_TRUE(value.toMatrix().isIdentity()); +} + +TEST(MatrixGenerator, PositionTranslatesMatrix) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kPositionInput, + QVector2D(100.0f, 50.0f)); + + olive::NodeValueTable table = GenerateTable(node); + const QVector3D mapped = + table.Get(olive::NodeValue::kMatrix).toMatrix().map(QVector3D(0, 0, 0)); + EXPECT_FLOAT_EQ(mapped.x(), 100.0f); + EXPECT_FLOAT_EQ(mapped.y(), 50.0f); + EXPECT_FLOAT_EQ(mapped.z(), 0.0f); +} + +TEST(MatrixGenerator, RotationAppliesAroundZAxis) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kRotationInput, 90.0); + + olive::NodeValueTable table = GenerateTable(node); + const QVector3D mapped = + table.Get(olive::NodeValue::kMatrix).toMatrix().map(QVector3D(1, 0, 0)); + EXPECT_NEAR(mapped.x(), 0.0f, 1e-5f); + EXPECT_NEAR(mapped.y(), 1.0f, 1e-5f); + EXPECT_NEAR(mapped.z(), 0.0f, 1e-5f); +} + +TEST(MatrixGenerator, PositionAppliesBeforeRotation) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kPositionInput, + QVector2D(10.0f, 0.0f)); + node->SetStandardValue(olive::MatrixGenerator::kRotationInput, 90.0); + + // The transform chain is translate * rotate, so (1,0) is first rotated to + // (0,1) and then shifted by the position + olive::NodeValueTable table = GenerateTable(node); + const QVector3D mapped = + table.Get(olive::NodeValue::kMatrix).toMatrix().map(QVector3D(1, 0, 0)); + EXPECT_NEAR(mapped.x(), 10.0f, 1e-5f); + EXPECT_NEAR(mapped.y(), 1.0f, 1e-5f); +} + +TEST(MatrixGenerator, UniformScaleUsesXForBothAxes) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kScaleInput, + QVector2D(2.0f, 3.0f)); + + // Uniform scale on: the X component drives both axes + node->SetStandardValue(olive::MatrixGenerator::kUniformScaleInput, true); + olive::NodeValueTable table = GenerateTable(node); + QVector3D mapped = + table.Get(olive::NodeValue::kMatrix).toMatrix().map(QVector3D(1, 1, 0)); + EXPECT_FLOAT_EQ(mapped.x(), 2.0f); + EXPECT_FLOAT_EQ(mapped.y(), 2.0f); + + // Uniform scale off: each axis uses its own component + node->SetStandardValue(olive::MatrixGenerator::kUniformScaleInput, false); + table = GenerateTable(node); + mapped = + table.Get(olive::NodeValue::kMatrix).toMatrix().map(QVector3D(1, 1, 0)); + EXPECT_FLOAT_EQ(mapped.x(), 2.0f); + EXPECT_FLOAT_EQ(mapped.y(), 3.0f); +} + +TEST(MatrixGenerator, AnchorPointShiftsMatrix) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::MatrixGenerator::kAnchorInput, + QVector2D(10.0f, 20.0f)); + + olive::NodeValueTable table = GenerateTable(node); + const QMatrix4x4 mat = table.Get(olive::NodeValue::kMatrix).toMatrix(); + + // The anchor itself maps back to the origin + const QVector3D anchor = mat.map(QVector3D(10.0f, 20.0f, 0.0f)); + EXPECT_FLOAT_EQ(anchor.x(), 0.0f); + EXPECT_FLOAT_EQ(anchor.y(), 0.0f); + + // ...and the origin is pushed away by the anchor offset + const QVector3D origin = mat.map(QVector3D(0, 0, 0)); + EXPECT_FLOAT_EQ(origin.x(), -10.0f); + EXPECT_FLOAT_EQ(origin.y(), -20.0f); +} + +TEST(ShapeNode, MetadataIsCorrect) +{ + olive::ShapeNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.shape")); + EXPECT_EQ(node.Name(), QStringLiteral("Shape")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(ShapeNode, InputDefaults) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + // From GeneratorWithMerge: base texture is the effect input + EXPECT_EQ(node->GetEffectInputID(), olive::GeneratorWithMerge::kBaseInput); + EXPECT_TRUE(node->GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(int(node->GetInputDataType(olive::GeneratorWithMerge::kBaseInput)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE( + node->IsInputKeyframable(olive::GeneratorWithMerge::kBaseInput)); + + // From ShapeNodeBase: position, size and color + EXPECT_EQ(node->GetStandardValue(olive::ShapeNodeBase::kPositionInput) + .value(), + QVector2D(0.0f, 0.0f)); + EXPECT_EQ(node->GetStandardValue(olive::ShapeNodeBase::kSizeInput) + .value(), + QVector2D(100.0f, 100.0f)); + const olive::core::Color color = + node->GetStandardValue(olive::ShapeNodeBase::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_FLOAT_EQ(color.green(), 0.0f); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); + + // Shape-specific: type combo defaults to rectangle, radius to 20 + EXPECT_EQ(int(node->GetInputDataType(olive::ShapeNode::kTypeInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node->GetStandardValue(olive::ShapeNode::kTypeInput).toInt(), + int(olive::ShapeNode::kRectangle)); + EXPECT_EQ(node->GetStandardValue(olive::ShapeNode::kRadiusInput).toDouble(), + 20.0); + EXPECT_EQ(node->GetInputProperty(olive::ShapeNode::kRadiusInput, + QStringLiteral("min")) + .toDouble(), + 0.0); +} + +TEST(ShapeNode, RetranslateSetsNamesAndComboStrings) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::ShapeNode::kTypeInput), + QStringLiteral("Type")); + EXPECT_EQ(node->GetInputName(olive::ShapeNode::kRadiusInput), + QStringLiteral("Radius")); + EXPECT_EQ(node->GetInputName(olive::ShapeNodeBase::kPositionInput), + QStringLiteral("Position")); + EXPECT_EQ(node->GetInputName(olive::ShapeNodeBase::kSizeInput), + QStringLiteral("Size")); + EXPECT_EQ(node->GetInputName(olive::ShapeNodeBase::kColorInput), + QStringLiteral("Color")); + EXPECT_EQ(node->GetInputName(olive::GeneratorWithMerge::kBaseInput), + QStringLiteral("Base")); + + const QStringList types = + node->GetComboBoxStrings(olive::ShapeNode::kTypeInput); + ASSERT_EQ(types.size(), 3); + EXPECT_EQ(types.at(int(olive::ShapeNode::kRectangle)), + QStringLiteral("Rectangle")); + EXPECT_EQ(types.at(int(olive::ShapeNode::kEllipse)), + QStringLiteral("Ellipse")); + EXPECT_EQ(types.at(int(olive::ShapeNode::kRoundedRectangle)), + QStringLiteral("Rounded Rectangle")); +} + +TEST(ShapeNode, RadiusHiddenUnlessRoundedRectangle) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + node->SetStandardValue(olive::ShapeNode::kTypeInput, + int(olive::ShapeNode::kRoundedRectangle)); + EXPECT_FALSE(node->IsInputHidden(olive::ShapeNode::kRadiusInput)); + + node->SetStandardValue(olive::ShapeNode::kTypeInput, + int(olive::ShapeNode::kEllipse)); + EXPECT_TRUE(node->IsInputHidden(olive::ShapeNode::kRadiusInput)); + + node->SetStandardValue(olive::ShapeNode::kTypeInput, + int(olive::ShapeNode::kRectangle)); + EXPECT_TRUE(node->IsInputHidden(olive::ShapeNode::kRadiusInput)); +} + +TEST(ShapeNode, ShaderCodeLoadsShapeAndMergeShaders) +{ + olive::ShapeNode node; + + const olive::ShaderCode shape = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("shape"))); + EXPECT_FALSE(shape.frag_code().isEmpty()); + EXPECT_TRUE(shape.frag_code().contains(QStringLiteral("type_in"))); + + // The merge shader comes from GeneratorWithMerge + const olive::ShaderCode merge = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("mrg"))); + EXPECT_FALSE(merge.frag_code().isEmpty()); + EXPECT_TRUE(merge.frag_code().contains(QStringLiteral("blend_in"))); + + // Unknown requests produce no code + const olive::ShaderCode unknown = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("bogus"))); + EXPECT_TRUE(unknown.frag_code().isEmpty()); + EXPECT_TRUE(unknown.vert_code().isEmpty()); +} + +TEST(ShapeNode, ValueWithoutBasePushesShapeJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams = TestVideoParams(); + olive::NodeValueTable table = GenerateTable(node, vparams); + + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), vparams.width()); + EXPECT_EQ(texture->params().height(), vparams.height()); + + auto *job = dynamic_cast(texture->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->GetShaderID(), QStringLiteral("shape")); + EXPECT_EQ(job->Get(QStringLiteral("resolution_in")).toVec2(), + vparams.square_resolution()); + EXPECT_EQ(job->Get(olive::ShapeNode::kTypeInput).toInt(), + int(olive::ShapeNode::kRectangle)); + EXPECT_EQ(job->Get(olive::ShapeNode::kRadiusInput).toDouble(), 20.0); +} + +TEST(ShapeNode, ValueWithBasePushesMergeJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + auto *constant = AddNode(&project); + + const olive::TexturePtr base = std::make_shared( + olive::VideoParams(64, 48, olive::core::PixelFormat::U8, 4)); + constant->SetTexture(base); + olive::Node::ConnectEdge(constant, + olive::NodeInput( + node, olive::GeneratorWithMerge::kBaseInput)); + + olive::NodeValueTable table = GenerateTable(node, TestVideoParams()); + + // With a base connected the generator composites onto it via the "mrg" + // merge shader + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), base->params().width()); + EXPECT_EQ(texture->params().height(), base->params().height()); + + auto *merge = dynamic_cast(texture->job()); + ASSERT_TRUE(merge); + EXPECT_EQ(merge->GetShaderID(), QStringLiteral("mrg")); + EXPECT_EQ(merge->Get(olive::MergeNode::kBaseIn).toTexture(), base); + + // The blend input carries the shape generation job, sized after the base + olive::TexturePtr blend = + merge->Get(olive::MergeNode::kBlendIn).toTexture(); + ASSERT_TRUE(blend); + ASSERT_TRUE(blend->IsJob()); + EXPECT_EQ(blend->params().width(), base->params().width()); + auto *shape_job = dynamic_cast(blend->job()); + ASSERT_TRUE(shape_job); + EXPECT_EQ(shape_job->GetShaderID(), QStringLiteral("shape")); + EXPECT_EQ(shape_job->Get(QStringLiteral("resolution_in")).toVec2(), + base->virtual_resolution()); +} + +TEST(SolidGenerator, MetadataIsCorrect) +{ + olive::SolidGenerator node; + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.solidgenerator")); + EXPECT_EQ(node.Name(), QStringLiteral("Solid")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(SolidGenerator, DefaultColorIsRed) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + EXPECT_EQ(int(node->GetInputDataType(olive::SolidGenerator::kColorInput)), + int(olive::NodeValue::kColor)); + + const olive::core::Color color = + node->GetStandardValue(olive::SolidGenerator::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_FLOAT_EQ(color.green(), 0.0f); + EXPECT_FLOAT_EQ(color.blue(), 0.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); +} + +TEST(SolidGenerator, RetranslateSetsInputName) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::SolidGenerator::kColorInput), + QStringLiteral("Color")); +} + +TEST(SolidGenerator, ShaderCodeContainsColorUniform) +{ + olive::SolidGenerator node; + + // The request is ignored, the solid shader is always returned + const olive::ShaderCode code = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("anything"))); + EXPECT_FALSE(code.frag_code().isEmpty()); + EXPECT_TRUE(code.frag_code().contains(QStringLiteral("color_in"))); +} + +TEST(SolidGenerator, ValuePushesShaderJobWithColor) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue( + olive::SolidGenerator::kColorInput, + QVariant::fromValue(olive::core::Color(0.25f, 0.5f, 0.75f, 1.0f))); + + const olive::VideoParams vparams = TestVideoParams(); + olive::NodeValueTable table = GenerateTable(node, vparams); + + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), vparams.width()); + EXPECT_EQ(texture->params().height(), vparams.height()); + EXPECT_EQ(int(texture->params().format()), + int(olive::core::PixelFormat::U8)); + + auto *job = dynamic_cast(texture->job()); + ASSERT_TRUE(job); + const olive::core::Color color = + job->Get(olive::SolidGenerator::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(NoiseGenerator, MetadataAndEffectFlags) +{ + olive::NoiseGeneratorNode node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.noise")); + EXPECT_EQ(node.Name(), QStringLiteral("Noise")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); + + EXPECT_TRUE(node.GetFlags() & olive::Node::kVideoEffect); + EXPECT_EQ(node.GetEffectInputID(), olive::NoiseGeneratorNode::kBaseIn); +} + +TEST(NoiseGenerator, InputDefaults) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + EXPECT_EQ(int(node->GetInputDataType(olive::NoiseGeneratorNode::kBaseIn)), + int(olive::NodeValue::kTexture)); + EXPECT_FALSE(node->IsInputKeyframable(olive::NoiseGeneratorNode::kBaseIn)); + + EXPECT_EQ( + int(node->GetInputDataType(olive::NoiseGeneratorNode::kStrengthInput)), + int(olive::NodeValue::kFloat)); + EXPECT_EQ(node->GetStandardValue(olive::NoiseGeneratorNode::kStrengthInput) + .toDouble(), + 0.2); + EXPECT_EQ(node->GetInputProperty(olive::NoiseGeneratorNode::kStrengthInput, + QStringLiteral("min")) + .toInt(), + 0); + EXPECT_EQ(node->GetInputProperty(olive::NoiseGeneratorNode::kStrengthInput, + QStringLiteral("view")) + .toInt(), + int(olive::FloatSlider::kPercentage)); + + EXPECT_EQ(int(node->GetInputDataType(olive::NoiseGeneratorNode::kColorInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node->GetStandardValue(olive::NoiseGeneratorNode::kColorInput) + .toBool()); +} + +TEST(NoiseGenerator, RetranslateSetsInputNames) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::NoiseGeneratorNode::kBaseIn), + QStringLiteral("Base")); + EXPECT_EQ(node->GetInputName(olive::NoiseGeneratorNode::kStrengthInput), + QStringLiteral("Strength")); + EXPECT_EQ(node->GetInputName(olive::NoiseGeneratorNode::kColorInput), + QStringLiteral("Color")); +} + +TEST(NoiseGenerator, ShaderCodeLoads) +{ + olive::NoiseGeneratorNode 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("strength_in"))); +} + +TEST(NoiseGenerator, ValueInsertsTimeAndUsesCacheParams) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams = TestVideoParams(); + const olive::TimeRange range(olive::rational(1, 2), olive::rational(3, 4)); + olive::NodeValueTable table = GenerateTable(node, vparams, range); + + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), vparams.width()); + EXPECT_EQ(texture->params().height(), vparams.height()); + + auto *job = dynamic_cast(texture->job()); + ASSERT_TRUE(job); + + // The noise is animated by the current time + const olive::NodeValue time = job->Get(QStringLiteral("time_in")); + ASSERT_EQ(int(time.type()), int(olive::NodeValue::kFloat)); + EXPECT_DOUBLE_EQ(time.toDouble(), range.in().toDouble()); + + EXPECT_DOUBLE_EQ(job->Get(olive::NoiseGeneratorNode::kStrengthInput) + .toDouble(), + 0.2); +} + +TEST(NoiseGenerator, ValueWithBaseUsesBaseParams) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + auto *constant = AddNode(&project); + + const olive::TexturePtr base = std::make_shared( + olive::VideoParams(64, 48, olive::core::PixelFormat::U8, 4)); + constant->SetTexture(base); + olive::Node::ConnectEdge( + constant, olive::NodeInput(node, olive::NoiseGeneratorNode::kBaseIn)); + + olive::NodeValueTable table = GenerateTable(node, TestVideoParams()); + + // The generated noise adopts the base texture's params, not the sequence's + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + EXPECT_EQ(texture->params().width(), base->params().width()); + EXPECT_EQ(texture->params().height(), base->params().height()); +} + +TEST(TextGeneratorV3, MetadataIsCorrect) +{ + olive::TextGeneratorV3 node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.text3")); + EXPECT_EQ(node.Name(), QStringLiteral("Text")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(TextGeneratorV3, InputDefaultsAndFlags) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + // The default text is a formatted HTML paragraph with the placeholder + // already substituted ("Sample Text" replaces %1 at construction) + const QString text = + node->GetStandardValue(olive::TextGeneratorV3::kTextInput).toString(); + EXPECT_TRUE(text.contains(QStringLiteral("Sample Text"))); + EXPECT_TRUE(text.contains(QStringLiteral("

GetStandardValue(olive::ShapeNodeBase::kSizeInput) + .value(), + QVector2D(400.0f, 300.0f)); + + // Alignment and argument inputs are hidden, non-rendered UI state + EXPECT_TRUE(node->IsInputHidden(olive::TextGeneratorV3::kVerticalAlignmentInput)); + EXPECT_TRUE( + node->GetInputFlags(olive::TextGeneratorV3::kVerticalAlignmentInput) & + olive::kInputFlagStatic); + EXPECT_EQ(node->GetStandardValue(olive::TextGeneratorV3::kVerticalAlignmentInput) + .toInt(), + int(olive::TextGeneratorV3::kVAlignTop)); + + EXPECT_TRUE(node->IsInputHidden(olive::TextGeneratorV3::kUseArgsInput)); + EXPECT_TRUE(node->GetInputFlags(olive::TextGeneratorV3::kUseArgsInput) & + olive::kInputFlagStatic); + EXPECT_TRUE(node->GetStandardValue(olive::TextGeneratorV3::kUseArgsInput) + .toBool()); + + EXPECT_TRUE(node->InputIsArray(olive::TextGeneratorV3::kArgsInput)); + EXPECT_EQ(node->InputArraySize(olive::TextGeneratorV3::kArgsInput), 0); + EXPECT_EQ(node->GetInputProperty(olive::TextGeneratorV3::kArgsInput, + QStringLiteral("arraystart")) + .toInt(), + 1); + + // TextGeneratorV3 has no color input, unlike ShapeNode + EXPECT_FALSE(node->HasInputWithID(olive::ShapeNodeBase::kColorInput)); +} + +TEST(TextGeneratorV3, RetranslateSetsNamesAndComboStrings) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV3::kTextInput), + QStringLiteral("Text")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV3::kVerticalAlignmentInput), + QStringLiteral("Vertical Alignment")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV3::kArgsInput), + QStringLiteral("Arguments")); + + const QStringList aligns = node->GetComboBoxStrings( + olive::TextGeneratorV3::kVerticalAlignmentInput); + ASSERT_EQ(aligns.size(), 3); + EXPECT_EQ(aligns.at(int(olive::TextGeneratorV3::kVAlignTop)), + QStringLiteral("Top")); + EXPECT_EQ(aligns.at(int(olive::TextGeneratorV3::kVAlignMiddle)), + QStringLiteral("Middle")); + EXPECT_EQ(aligns.at(int(olive::TextGeneratorV3::kVAlignBottom)), + QStringLiteral("Bottom")); +} + +TEST(TextGeneratorV3, FormatStringSubstitutesArguments) +{ + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("Hello %1"), + { QStringLiteral("world") }), + QStringLiteral("Hello world")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString( + QStringLiteral("%1 %2 %1"), + { QStringLiteral("a"), QStringLiteral("b") }), + QStringLiteral("a b a")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString( + QStringLiteral("%1%2"), + { QStringLiteral("a"), QStringLiteral("b") }), + QStringLiteral("ab")); + + // Multi-digit indices are supported + QStringList args; + for (int i = 0; i < 12; i++) { + args.append(QString::number(i + 1)); + } + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%12"), args), + QStringLiteral("12")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString( + QStringLiteral("%01"), { QStringLiteral("x") }), + QStringLiteral("x")); +} + +TEST(TextGeneratorV3, FormatStringHandlesEdgeCases) +{ + // Double percent escapes to a literal one, even without arguments + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("100%%"), {}), + QStringLiteral("100%")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%%"), + { QStringLiteral("x") }), + QStringLiteral("%")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%%%1"), + { QStringLiteral("x") }), + QStringLiteral("%x")); + + // Out-of-range and zero indices expand to nothing + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%1"), {}), + QStringLiteral("")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%5"), + { QStringLiteral("a") }), + QStringLiteral("")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%0"), + { QStringLiteral("a") }), + QStringLiteral("")); + + // A percent not followed by a digit or percent is kept literally + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("%x"), + { QStringLiteral("a") }), + QStringLiteral("%x")); + EXPECT_EQ(olive::TextGeneratorV3::FormatString(QStringLiteral("end%"), + { QStringLiteral("a") }), + QStringLiteral("end%")); +} + +TEST(TextGeneratorV3, AlignmentConversions) +{ + EXPECT_EQ(olive::TextGeneratorV3::GetQtAlignmentFromOurs( + olive::TextGeneratorV3::kVAlignTop), + Qt::AlignTop); + EXPECT_EQ(olive::TextGeneratorV3::GetQtAlignmentFromOurs( + olive::TextGeneratorV3::kVAlignMiddle), + Qt::AlignVCenter); + EXPECT_EQ(olive::TextGeneratorV3::GetQtAlignmentFromOurs( + olive::TextGeneratorV3::kVAlignBottom), + Qt::AlignBottom); + + // Unknown values map to no alignment + EXPECT_EQ(olive::TextGeneratorV3::GetQtAlignmentFromOurs( + static_cast(-1)), + Qt::Alignment()); + + EXPECT_EQ(int(olive::TextGeneratorV3::GetOurAlignmentFromQts(Qt::AlignTop)), + int(olive::TextGeneratorV3::kVAlignTop)); + EXPECT_EQ( + int(olive::TextGeneratorV3::GetOurAlignmentFromQts(Qt::AlignVCenter)), + int(olive::TextGeneratorV3::kVAlignMiddle)); + EXPECT_EQ( + int(olive::TextGeneratorV3::GetOurAlignmentFromQts(Qt::AlignBottom)), + int(olive::TextGeneratorV3::kVAlignBottom)); + + // Anything without a vertical component defaults to top + EXPECT_EQ(int(olive::TextGeneratorV3::GetOurAlignmentFromQts(Qt::AlignLeft)), + int(olive::TextGeneratorV3::kVAlignTop)); + EXPECT_EQ( + int(olive::TextGeneratorV3::GetOurAlignmentFromQts(Qt::AlignHCenter)), + int(olive::TextGeneratorV3::kVAlignTop)); +} + +TEST(TextGeneratorV3, GetVerticalAlignmentFollowsInput) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + EXPECT_EQ(int(node->GetVerticalAlignment()), + int(olive::TextGeneratorV3::kVAlignTop)); + + node->SetStandardValue(olive::TextGeneratorV3::kVerticalAlignmentInput, + int(olive::TextGeneratorV3::kVAlignBottom)); + EXPECT_EQ(int(node->GetVerticalAlignment()), + int(olive::TextGeneratorV3::kVAlignBottom)); + + node->SetStandardValue(olive::TextGeneratorV3::kVerticalAlignmentInput, + int(olive::TextGeneratorV3::kVAlignMiddle)); + EXPECT_EQ(int(node->GetVerticalAlignment()), + int(olive::TextGeneratorV3::kVAlignMiddle)); +} + +TEST(TextGeneratorV3, ValueFormatsTextIntoGenerateJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV3::kTextInput, + QStringLiteral("A %1 B %2")); + node->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + node->SetStandardValue(olive::TextGeneratorV3::kArgsInput, + QStringLiteral("x"), 0); + node->SetStandardValue(olive::TextGeneratorV3::kArgsInput, + QStringLiteral("y"), 1); + + // Text is always rendered to an 8-bit buffer regardless of sequence depth + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::F32, 4); + olive::NodeValueTable table = GenerateTable(node, vparams); + + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), vparams.width()); + EXPECT_EQ(int(texture->params().format()), + int(olive::core::PixelFormat::U8)); + + auto *job = dynamic_cast(texture->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(olive::TextGeneratorV3::kTextInput).toString(), + QStringLiteral("A x B y")); +} + +TEST(TextGeneratorV3, EmptyTextOutputsNoTextureWithoutBase) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV3::kTextInput, QString()); + + olive::NodeValueTable table = GenerateTable(node, TestVideoParams()); + + // No text and no base: nothing renderable comes out + EXPECT_TRUE(GetOutputTexture(table) == nullptr); +} + +TEST(TextGeneratorV3, EmptyTextPassesBaseThrough) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV3::kTextInput, QString()); + + auto *constant = AddNode(&project); + const olive::TexturePtr base = std::make_shared( + olive::VideoParams(64, 48, olive::core::PixelFormat::U8, 4)); + constant->SetTexture(base); + olive::Node::ConnectEdge(constant, + olive::NodeInput( + node, olive::GeneratorWithMerge::kBaseInput)); + + olive::NodeValueTable table = GenerateTable(node, TestVideoParams()); + + // With empty text the base is passed through untouched instead of running + // the text generation job + EXPECT_EQ(GetOutputTexture(table), base); +} + +TEST(PolygonGenerator, MetadataIsCorrect) +{ + olive::PolygonGenerator node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.polygon")); + EXPECT_EQ(node.Name(), QStringLiteral("Polygon")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); +} + +TEST(PolygonGenerator, DefaultPentagonPoints) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + EXPECT_TRUE(node->InputIsArray(olive::PolygonGenerator::kPointsInput)); + ASSERT_EQ(node->InputArraySize(olive::PolygonGenerator::kPointsInput), 5); + + // "The Default Pentagon(tm)", as named in the implementation + const double expected[5][2] = { + { 0, -135 }, { 135, -45 }, { 90, 120 }, { -90, 120 }, { -135, -45 } + }; + for (int i = 0; i < 5; i++) { + EXPECT_DOUBLE_EQ(node->GetSplitStandardValueOnTrack( + olive::PolygonGenerator::kPointsInput, 0, i) + .toDouble(), + expected[i][0]) + << "Wrong X for point " << i; + EXPECT_DOUBLE_EQ(node->GetSplitStandardValueOnTrack( + olive::PolygonGenerator::kPointsInput, 1, i) + .toDouble(), + expected[i][1]) + << "Wrong Y for point " << i; + } + + // Polygons default to white + const olive::core::Color color = + node->GetStandardValue(olive::PolygonGenerator::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); +} + +TEST(PolygonGenerator, RetranslateSetsInputNames) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::PolygonGenerator::kPointsInput), + QStringLiteral("Points")); + EXPECT_EQ(node->GetInputName(olive::PolygonGenerator::kColorInput), + QStringLiteral("Color")); + EXPECT_EQ(node->GetInputName(olive::GeneratorWithMerge::kBaseInput), + QStringLiteral("Base")); +} + +TEST(PolygonGenerator, ShaderCodeLoadsRgbAndMergeShaders) +{ + olive::PolygonGenerator node; + + // The generated alpha mask is tinted through the rgb shader + 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 merge = node.GetShaderCode( + olive::Node::ShaderRequest(QStringLiteral("mrg"))); + EXPECT_FALSE(merge.frag_code().isEmpty()); + EXPECT_TRUE(merge.frag_code().contains(QStringLiteral("blend_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(PolygonGenerator, ValueWithoutBasePushesNestedGenerateJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::F32, 4); + olive::NodeValueTable table = GenerateTable(node, vparams); + + // Without a base, the output is the rgb tint job at sequence params + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), vparams.width()); + EXPECT_EQ(int(texture->params().format()), + int(olive::core::PixelFormat::F32)); + + auto *rgb = dynamic_cast(texture->job()); + ASSERT_TRUE(rgb); + EXPECT_EQ(rgb->GetShaderID(), QStringLiteral("rgb")); + + const olive::core::Color color = + rgb->Get(olive::PolygonGenerator::kColorInput).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); + + // Its texture input is the polygon's CPU-side GenerateJob, always 8-bit + olive::TexturePtr mask = rgb->Get(QStringLiteral("texture_in")).toTexture(); + ASSERT_TRUE(mask); + ASSERT_TRUE(mask->IsJob()); + EXPECT_EQ(int(mask->params().format()), int(olive::core::PixelFormat::U8)); + EXPECT_TRUE(dynamic_cast(mask->job())); +} diff --git a/tests/gtest/render_misc_test.cpp b/tests/gtest/render_misc_test.cpp new file mode 100644 index 000000000..8cc4d7fa7 --- /dev/null +++ b/tests/gtest/render_misc_test.cpp @@ -0,0 +1,713 @@ +#include + +#include +#include +#include +#include + +#include "codec/conformmanager.h" +#include "config/config.h" +#include "node/color/colormanager/colormanager.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "render/backend/dynamicrenderer.h" +#include "render/colorprocessor.h" +#include "render/diskmanager.h" +#include "render/job/colortransformjob.h" +#include "render/job/shaderjob.h" +#include "render/previewautocacher.h" +#include "render/renderer.h" +#include "render/rendermanager.h" +#include "render/texture.h" +#include "render/videoparams.h" + +namespace +{ + +// CPU-only olive::Renderer implementation that records every call so the +// renderer-core code paths (texture cache, shader cache, color management +// fallback) can be verified without a GL/Vulkan backend. +class StubRenderer : public olive::Renderer { +public: + StubRenderer() + : create_texture_count(0) + , destroy_texture_count(0) + , create_shader_count(0) + , destroy_shader_count(0) + , upload_count(0) + , download_count(0) + , flush_count(0) + , blit_count(0) + , destroy_internal_count(0) + , next_handle(0) + , fail_create_texture(false) + , fail_create_shader(false) + , last_create_width(0) + , last_create_height(0) + , last_create_depth(0) + , last_create_channel_count(0) + , last_create_data(nullptr) + , last_create_linesize(0) + , last_upload_linesize(0) + , last_download_linesize(0) + , last_blit_destination(nullptr) + , last_blit_clear(false) + { + } + + bool Init() override + { + return true; + } + + void PostDestroy() override + { + } + + void PostInit() override + { + } + + void ClearDestination(olive::Texture *texture, double r, double g, double b, + double a) override + { + } + + QVariant CreateNativeShader(olive::ShaderCode code) override + { + create_shader_count++; + if (fail_create_shader) { + return QVariant(); + } + return QVariant(QStringLiteral("shader%1").arg(create_shader_count)); + } + + void DestroyNativeShader(QVariant shader) override + { + destroy_shader_count++; + } + + void UploadToTexture(const QVariant &handle, const olive::VideoParams ¶ms, + const void *data, int linesize) override + { + upload_count++; + last_upload_handle = handle; + last_upload_linesize = linesize; + } + + void DownloadFromTexture(const QVariant &handle, + const olive::VideoParams ¶ms, void *data, + int linesize) override + { + download_count++; + last_download_handle = handle; + last_download_linesize = linesize; + } + + void Flush() override + { + flush_count++; + } + + olive::Color GetPixelFromTexture(olive::Texture *texture, + const QPointF &pt) override + { + return olive::Color(); + } + + int create_texture_count; + int destroy_texture_count; + int create_shader_count; + int destroy_shader_count; + int upload_count; + int download_count; + int flush_count; + int blit_count; + int destroy_internal_count; + + int next_handle; + + bool fail_create_texture; + bool fail_create_shader; + + int last_create_width; + int last_create_height; + int last_create_depth; + int last_create_channel_count; + const void *last_create_data; + int last_create_linesize; + + QVariant last_upload_handle; + int last_upload_linesize; + QVariant last_download_handle; + int last_download_linesize; + + QVariant last_blit_shader; + olive::Texture *last_blit_destination; + olive::VideoParams last_blit_params; + bool last_blit_clear; + +protected: + void Blit(QVariant shader, olive::AcceleratedJob &job, + olive::Texture *destination, + olive::VideoParams destination_params, + bool clear_destination) override + { + blit_count++; + last_blit_shader = shader; + last_blit_destination = destination; + last_blit_params = destination_params; + last_blit_clear = clear_destination; + } + + QVariant CreateNativeTexture(int width, int height, int depth, + olive::PixelFormat format, int channel_count, + const void *data, int linesize) override + { + create_texture_count++; + last_create_width = width; + last_create_height = height; + last_create_depth = depth; + last_create_channel_count = channel_count; + last_create_data = data; + last_create_linesize = linesize; + if (fail_create_texture) { + return QVariant(); + } + return QVariant(++next_handle); + } + + void DestroyNativeTexture(QVariant texture) override + { + destroy_texture_count++; + } + + void DestroyInternal() override + { + destroy_internal_count++; + } +}; + +olive::ColorProcessorPtr CreateIdentityProcessor() +{ + olive::ColorManager::SetUpDefaultConfig(); + + OCIO::MatrixTransformRcPtr transform = OCIO::MatrixTransform::Create(); + transform->setDirection(OCIO::TRANSFORM_DIR_FORWARD); + + return olive::ColorProcessor::Create( + olive::ColorManager::GetDefaultConfig()->getProcessor(transform)); +} + +} // namespace + +// Verifies the DynamicRenderer constructor stores the requested backend name +// lowercased and that IsOpenGL()/IsVulkan() reflect it before any Load(). +TEST(DynamicRenderer, ConstructorNormalizesBackendName) +{ + olive::DynamicRenderer gl(QStringLiteral("OpenGL")); + EXPECT_EQ(gl.backend_name(), QStringLiteral("opengl")); + EXPECT_TRUE(gl.IsOpenGL()); + EXPECT_FALSE(gl.IsVulkan()); + + olive::DynamicRenderer vk(QStringLiteral("VULKAN")); + EXPECT_EQ(vk.backend_name(), QStringLiteral("vulkan")); + EXPECT_TRUE(vk.IsVulkan()); + EXPECT_FALSE(vk.IsOpenGL()); +} + +// Documents that an unrecognized backend name is kept verbatim (even though +// LibraryFilename() silently maps it to the OpenGL library basename), so the +// IsOpenGL()/IsVulkan() predicates both report false for it. +TEST(DynamicRenderer, UnknownBackendNameIsReportedVerbatim) +{ + olive::DynamicRenderer renderer(QStringLiteral("Metal")); + EXPECT_EQ(renderer.backend_name(), QStringLiteral("metal")); + EXPECT_FALSE(renderer.IsOpenGL()); + EXPECT_FALSE(renderer.IsVulkan()); +} + +// Before Load() succeeds there is no backend handle, so the metadata and +// context accessors must return safe defaults instead of dereferencing null +// function pointers. +TEST(DynamicRenderer, AccessorsBeforeLoadReturnDefaults) +{ + olive::DynamicRenderer renderer(QStringLiteral("opengl")); + + EXPECT_EQ(renderer.OpenGLContext(), nullptr); + + OakRenderBackendInfo info = {}; + EXPECT_FALSE(renderer.GetBackendInfo(&info)); + EXPECT_FALSE(renderer.GetBackendInfo(nullptr)); +} + +// Lifecycle entry points must tolerate being called without a loaded backend: +// each one guards on the null handle/function table and does nothing. +TEST(DynamicRenderer, PreLoadLifecycleCallsAreSafeNoOps) +{ + olive::DynamicRenderer renderer(QStringLiteral("opengl")); + + renderer.PostInit(); + renderer.PostDestroy(); + renderer.AttachOutputTexture(nullptr); + renderer.DetachOutputTexture(); + renderer.Destroy(); + // Destruction after an explicit Destroy() must also be safe. +} + +// Once a backend is loaded, Load() must short-circuit on the existing handle +// instead of reloading the library a second time. +TEST(DynamicRenderer, SecondLoadReturnsImmediately) +{ +#ifndef OAK_ENABLE_DYNAMIC_RENDER_BACKEND + GTEST_SKIP() << "Dynamic render backend is not enabled in this build"; +#else + olive::DynamicRenderer renderer(QStringLiteral("opengl")); + if (!renderer.Load()) { + GTEST_SKIP() + << "opengl backend library could not be loaded in this environment"; + } + + EXPECT_TRUE(renderer.Load()); + EXPECT_TRUE(renderer.IsOpenGL()); + EXPECT_EQ(renderer.backend_name(), QStringLiteral("opengl")); +#endif +} + +// Renderer::CreateTexture must wrap the native handle produced by +// CreateNativeTexture into a live Texture that mirrors the requested params. +TEST(RendererTextureCache, CreateTextureWrapsNativeHandle) +{ + StubRenderer renderer; + const olive::VideoParams params(64, 32, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + olive::TexturePtr texture = renderer.CreateTexture(params); + ASSERT_NE(texture, nullptr); + EXPECT_FALSE(texture->IsDummy()); + EXPECT_EQ(texture->id(), QVariant(1)); + EXPECT_EQ(texture->width(), 64); + EXPECT_EQ(texture->height(), 32); + EXPECT_EQ(texture->format(), olive::PixelFormat::U8); + EXPECT_EQ(texture->channel_count(), + int(olive::VideoParams::kRGBAChannelCount)); + EXPECT_EQ(renderer.create_texture_count, 1); + + renderer.Destroy(); +} + +// A null native handle (backend allocation failure) must propagate as a null +// TexturePtr rather than a dummy texture. +TEST(RendererTextureCache, FailedNativeTextureCreateReturnsNull) +{ + StubRenderer renderer; + renderer.fail_create_texture = true; + + const olive::VideoParams params(64, 64, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + EXPECT_EQ(renderer.CreateTexture(params), nullptr); + EXPECT_EQ(renderer.create_texture_count, 1); +} + +// Destroying a texture returns its native handle to the cache; a matching +// CreateTexture must reuse it without calling into the backend again, and +// flush so pending backend work is visible to the recycled texture. +TEST(RendererTextureCache, DestroyedTextureIsReusedFromCache) +{ + StubRenderer renderer; + const olive::VideoParams params(64, 64, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + { + olive::TexturePtr texture = renderer.CreateTexture(params); + ASSERT_NE(texture, nullptr); + EXPECT_EQ(texture->id(), QVariant(1)); + } + EXPECT_EQ(renderer.create_texture_count, 1); + + olive::TexturePtr reused = renderer.CreateTexture(params); + ASSERT_NE(reused, nullptr); + EXPECT_EQ(reused->id(), QVariant(1)); + EXPECT_EQ(renderer.create_texture_count, 1); + EXPECT_EQ(renderer.flush_count, 1); + + // A different size must miss the cache and allocate natively again. + const olive::VideoParams other(32, 32, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::TexturePtr fresh = renderer.CreateTexture(other); + ASSERT_NE(fresh, nullptr); + EXPECT_EQ(fresh->id(), QVariant(2)); + EXPECT_EQ(renderer.create_texture_count, 2); + + reused.reset(); + fresh.reset(); + renderer.Destroy(); + EXPECT_EQ(renderer.destroy_texture_count, 2); +} + +// When pixel data is supplied for a cache hit, the renderer must upload it +// into the recycled native handle rather than reallocating. +TEST(RendererTextureCache, CachedTextureReusedWithDataTriggersUpload) +{ + StubRenderer renderer; + const olive::VideoParams params(16, 16, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + { + olive::TexturePtr texture = renderer.CreateTexture(params); + ASSERT_NE(texture, nullptr); + } + + char data[16 * 16 * 4] = {}; + olive::TexturePtr texture = + renderer.CreateTexture(params, data, 16 * 4); + ASSERT_NE(texture, nullptr); + EXPECT_EQ(renderer.create_texture_count, 1); + EXPECT_EQ(renderer.upload_count, 1); + EXPECT_EQ(renderer.last_upload_handle, QVariant(1)); + EXPECT_EQ(renderer.last_upload_linesize, 16 * 4); + + texture.reset(); + renderer.Destroy(); +} + +// On a cache miss with initial data, the data pointer and linesize must be +// forwarded to CreateNativeTexture untouched. +TEST(RendererTextureCache, CreateWithDataForwardsToNativeCreate) +{ + StubRenderer renderer; + const olive::VideoParams params(8, 8, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + char data[8 * 8 * 4] = {}; + olive::TexturePtr texture = renderer.CreateTexture(params, data, 8 * 4); + ASSERT_NE(texture, nullptr); + EXPECT_EQ(renderer.create_texture_count, 1); + EXPECT_EQ(renderer.last_create_data, static_cast(data)); + EXPECT_EQ(renderer.last_create_linesize, 8 * 4); + EXPECT_EQ(renderer.last_create_width, 8); + EXPECT_EQ(renderer.last_create_height, 8); + EXPECT_EQ(renderer.last_create_channel_count, + int(olive::VideoParams::kRGBAChannelCount)); + EXPECT_EQ(renderer.upload_count, 0); + + texture.reset(); + renderer.Destroy(); +} + +// GetDefaultShader() must compile the built-in shader once and cache it; +// Destroy() releases it exactly once through DestroyNativeShader. +TEST(RendererShaderCache, DefaultShaderCreatedOnceAndReleasedOnDestroy) +{ + StubRenderer renderer; + + const QVariant first = renderer.GetDefaultShader(); + const QVariant second = renderer.GetDefaultShader(); + EXPECT_FALSE(first.isNull()); + EXPECT_EQ(first, second); + EXPECT_EQ(renderer.create_shader_count, 1); + + renderer.Destroy(); + EXPECT_EQ(renderer.destroy_shader_count, 1); + EXPECT_EQ(renderer.destroy_internal_count, 1); + + // A repeated Destroy() must not release the shader a second time. + renderer.Destroy(); + EXPECT_EQ(renderer.destroy_shader_count, 1); + EXPECT_EQ(renderer.destroy_internal_count, 2); +} + +// Texture::Upload/Download must forward the native id, params, and linesize +// to the owning renderer while it is alive. +TEST(TextureIo, UploadDownloadForwardToRenderer) +{ + StubRenderer renderer; + const olive::VideoParams params(16, 16, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + olive::TexturePtr texture = renderer.CreateTexture(params); + ASSERT_NE(texture, nullptr); + + char data[16 * 16 * 4] = {}; + texture->Upload(data, 16 * 4); + EXPECT_EQ(renderer.upload_count, 1); + EXPECT_EQ(renderer.last_upload_handle, texture->id()); + EXPECT_EQ(renderer.last_upload_linesize, 16 * 4); + + texture->Download(data, 16 * 4); + EXPECT_EQ(renderer.download_count, 1); + EXPECT_EQ(renderer.last_download_handle, texture->id()); + EXPECT_EQ(renderer.last_download_linesize, 16 * 4); + + texture.reset(); + renderer.Destroy(); +} + +// A dummy texture (no backend renderer) must expose its params, report +// IsDummy(), and make Upload/Download harmless no-ops. +TEST(TextureDummy, AccessorsAndNoOpIo) +{ + const olive::VideoParams params(128, 64, olive::PixelFormat::F16, + olive::VideoParams::kRGBAChannelCount); + olive::Texture texture(params); + + EXPECT_TRUE(texture.IsDummy()); + EXPECT_EQ(texture.renderer(), nullptr); + EXPECT_TRUE(texture.id().isNull()); + EXPECT_EQ(texture.width(), 128); + EXPECT_EQ(texture.height(), 64); + EXPECT_EQ(texture.virtual_resolution(), QVector2D(128, 64)); + EXPECT_EQ(texture.format(), olive::PixelFormat::F16); + EXPECT_EQ(texture.channel_count(), + int(olive::VideoParams::kRGBAChannelCount)); + EXPECT_EQ(texture.divider(), 1); + EXPECT_EQ(texture.pixel_aspect_ratio(), olive::rational(1)); + EXPECT_FALSE(texture.IsJob()); + EXPECT_EQ(texture.job(), nullptr); + + EXPECT_EQ(int(olive::Texture::kDefaultInterpolation), + int(olive::Texture::kMipmappedLinear)); + + char data[4] = {}; + texture.Upload(data, 4); + texture.Download(data, 4); +} + +// Textures can carry a CPU-side AcceleratedJob instead of a native handle; +// the job must be owned and retrievable through job(). +TEST(TextureJob, JobTextureExposesJob) +{ + const olive::VideoParams params(32, 32, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::TexturePtr texture = + olive::Texture::Job(params, olive::ShaderJob()); + + ASSERT_NE(texture, nullptr); + EXPECT_TRUE(texture->IsDummy()); + EXPECT_TRUE(texture->IsJob()); + ASSERT_NE(texture->job(), nullptr); + EXPECT_EQ(texture->params().width(), 32); +} + +// When the color-management shader cannot be compiled, BlitColorManaged must +// fall back to a plain textured blit with the default shader instead of +// failing silently or crashing. +TEST(RendererColorManagement, FallsBackToDefaultShaderWhenCompilationFails) +{ + StubRenderer renderer; + renderer.fail_create_shader = true; + + olive::ColorProcessorPtr processor = CreateIdentityProcessor(); + ASSERT_TRUE(processor); + + olive::ColorTransformJob job; + job.SetColorProcessor(processor); + + const olive::VideoParams params(64, 64, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + renderer.BlitColorManaged(job, params); + + // One blit to the null destination overload, with the (failed) default + // shader handle and the job's clear-destination flag preserved. + EXPECT_EQ(renderer.blit_count, 1); + EXPECT_EQ(renderer.last_blit_destination, nullptr); + EXPECT_TRUE(renderer.last_blit_shader.isNull()); + EXPECT_TRUE(renderer.last_blit_clear); + EXPECT_EQ(renderer.last_blit_params.width(), 64); + + renderer.Destroy(); +} + +// GetColorContext must cache the compiled color pipeline per processor id: +// repeating the same job reuses the context, while an override id forces a +// second compilation. +TEST(RendererColorManagement, CachesColorContextPerProcessorId) +{ + StubRenderer renderer; + + olive::ColorProcessorPtr processor = CreateIdentityProcessor(); + ASSERT_TRUE(processor); + + olive::ColorTransformJob job; + job.SetColorProcessor(processor); + + const olive::VideoParams params(64, 64, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + + renderer.BlitColorManaged(job, params); + EXPECT_EQ(renderer.blit_count, 1); + EXPECT_EQ(renderer.create_shader_count, 1); + EXPECT_FALSE(renderer.last_blit_shader.isNull()); + + // Same processor id: color context cache hit, no new shader compilation. + renderer.BlitColorManaged(job, params); + EXPECT_EQ(renderer.blit_count, 2); + EXPECT_EQ(renderer.create_shader_count, 1); + + // A distinct override id bypasses the cached context and compiles again. + olive::ColorTransformJob other_job; + other_job.SetColorProcessor(processor); + other_job.SetOverrideID(QStringLiteral("other-context")); + renderer.BlitColorManaged(other_job, params); + EXPECT_EQ(renderer.blit_count, 3); + EXPECT_EQ(renderer.create_shader_count, 2); + + renderer.Destroy(); +} + +// The destination overload of BlitColorManaged must forward the destination +// texture and its params to the backend blit. +TEST(RendererColorManagement, BlitToDestinationForwardsTexture) +{ + StubRenderer renderer; + + olive::ColorProcessorPtr processor = CreateIdentityProcessor(); + ASSERT_TRUE(processor); + + olive::ColorTransformJob job; + job.SetColorProcessor(processor); + + const olive::VideoParams params(32, 16, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::TexturePtr destination = renderer.CreateTexture(params); + ASSERT_NE(destination, nullptr); + + renderer.BlitColorManaged(job, destination.get()); + EXPECT_EQ(renderer.blit_count, 1); + EXPECT_EQ(renderer.last_blit_destination, destination.get()); + EXPECT_EQ(renderer.last_blit_params.width(), 32); + EXPECT_EQ(renderer.last_blit_params.height(), 16); + + destination.reset(); + renderer.Destroy(); +} + +class RenderMiscAutoCacherTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + // Use the dummy render backend so PreviewAutoCacher can be exercised + // without initializing OpenGL/Vulkan in the unit-test process. + olive::Config::Current()[QStringLiteral("GraphicsBackend")] = + QStringLiteral("dummy"); + + olive::DiskManager::CreateInstance(); + olive::ConformManager::CreateInstance(); + olive::RenderManager::CreateInstance(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + olive::RenderManager::DestroyInstance(); + olive::ConformManager::DestroyInstance(); + olive::DiskManager::DestroyInstance(); + } + + std::unique_ptr project_; +}; + +// Requesting a new single frame must cancel the previously queued (not yet +// dispatched) single-frame ticket: the old ticket finishes without a result +// and the new one becomes the pending render. +TEST_F(RenderMiscAutoCacherTest, GetSingleFrameCancelsPreviouslyQueuedTicket) +{ + auto *viewer = new olive::ViewerOutput(); + viewer->setParent(project_.get()); + + olive::PreviewAutoCacher cacher; + + olive::RenderTicketPtr first = + cacher.GetSingleFrame(viewer, olive::rational(0)); + olive::RenderTicketPtr second = + cacher.GetSingleFrame(viewer, olive::rational(1)); + + ASSERT_NE(first, nullptr); + ASSERT_NE(second, nullptr); + EXPECT_NE(first, second); + + EXPECT_EQ(first->GetFinishCount(), 1); + EXPECT_FALSE(first->IsRunning()); + EXPECT_FALSE(first->HasResult()); + + EXPECT_TRUE(second->IsRunning()); +} + +// SetProject() early-outs when the same project (or null twice) is passed; +// neither call may disturb the copied graph or leak tasks. +TEST_F(RenderMiscAutoCacherTest, SetSameProjectTwiceIsNoOp) +{ + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + cacher.SetProject(project_.get()); + cacher.SetProject(nullptr); + cacher.SetProject(nullptr); + EXPECT_FALSE(cacher.IsRenderingCustomRange()); +} + +// ForceCacheRange queues the requested frames through TryRender; with the +// dummy backend each ticket finishes without a result, and the cacher must +// still emit StopCacheProxyTasks when the range iterator is exhausted. +TEST_F(RenderMiscAutoCacherTest, + ForceCacheRangeSchedulesVideoJobAndSignalsCompletion) +{ + auto *viewer = new olive::ViewerOutput(); + viewer->setParent(project_.get()); + viewer->SetVideoParams( + olive::VideoParams(64, 64, olive::rational(1, 25), + olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount)); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks); + + cacher.ForceCacheRange( + viewer, olive::TimeRange(olive::rational(0), olive::rational(1, 25))); + + EXPECT_GE(stop_spy.count(), 1); + EXPECT_FALSE(cacher.IsRenderingCustomRange()); + + // Deliver the queued RenderTicketWatcher::Finished emissions so the + // completed watchers are reaped before teardown. + QCoreApplication::processEvents(); + + cacher.SetProject(nullptr); +} + +// A conform-ready notification with no conform-blocked audio ranges must be a +// harmless no-op. +TEST_F(RenderMiscAutoCacherTest, ConformReadyWithoutPendingConformsIsNoOp) +{ + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + emit olive::ConformManager::instance()->ConformReady(); + + cacher.SetProject(nullptr); +} + +// Cancelling cache-proxy tasks must drop pending video jobs without touching +// the (empty) running task list. +TEST_F(RenderMiscAutoCacherTest, CacheProxyTaskCancelledClearsPendingJobs) +{ + auto *viewer = new olive::ViewerOutput(); + viewer->setParent(project_.get()); + + olive::PreviewAutoCacher cacher; + + // No project is set, so the forced range sits in the pending queue. + cacher.ForceCacheRange( + viewer, olive::TimeRange(olive::rational(0), olive::rational(1))); + + EXPECT_TRUE(QMetaObject::invokeMethod(&cacher, "CacheProxyTaskCancelled", + Qt::DirectConnection)); + + cacher.SetProject(nullptr); +} diff --git a/tests/gtest/sequence_test.cpp b/tests/gtest/sequence_test.cpp new file mode 100644 index 000000000..5d334a106 --- /dev/null +++ b/tests/gtest/sequence_test.cpp @@ -0,0 +1,566 @@ +#include + +#include + +#include "node/block/clip/clip.h" +#include "node/color/colormanager/colormanager.h" +#include "node/math/math/math.h" +#include "node/output/track/track.h" +#include "node/output/track/tracklist.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/sequence/sequence.h" + +namespace +{ + +olive::Sequence *CreateSequence(olive::Project *project) +{ + auto *sequence = new olive::Sequence(); + sequence->setParent(project); + return sequence; +} + +olive::Track *CreateTrack(olive::Project *project) +{ + auto *track = new olive::Track(); + track->setParent(project); + return track; +} + +olive::ClipBlock *CreateClip(olive::Project *project, + const olive::core::rational &length) +{ + auto *clip = new olive::ClipBlock(); + clip->setParent(project); + clip->set_length_and_media_out(length); + return clip; +} + +// Mirrors what TimelineAddTrackCommand::redo() does to wire a track into a +// sequence: grow the track input array, then connect the edge +void AppendTrackToList(olive::TrackList *list, olive::Track *track) +{ + list->ArrayAppend(); + olive::Node::ConnectEdge(track, + list->track_input(list->ArraySize() - 1)); +} + +} // namespace + +TEST(Sequence, DefaultState) +{ + olive::Sequence sequence; + + EXPECT_EQ(sequence.Name(), QStringLiteral("Sequence")); + EXPECT_EQ(sequence.id(), + QStringLiteral("org.olivevideoeditor.Olive.sequence")); + EXPECT_TRUE(sequence.Category().contains(olive::Node::kCategoryProject)); + + // Track input ids are generated from kTrackInputFormat + EXPECT_EQ(olive::Sequence::kTrackInputFormat.arg(0), + QStringLiteral("track_in_0")); + EXPECT_EQ(olive::Sequence::kTrackInputFormat.arg(1), + QStringLiteral("track_in_1")); + EXPECT_EQ(olive::Sequence::kTrackInputFormat.arg(2), + QStringLiteral("track_in_2")); + + // One track list per track type, all empty + olive::TrackList *video = sequence.track_list(olive::Track::kVideo); + olive::TrackList *audio = sequence.track_list(olive::Track::kAudio); + olive::TrackList *subtitle = sequence.track_list(olive::Track::kSubtitle); + ASSERT_NE(video, nullptr); + ASSERT_NE(audio, nullptr); + ASSERT_NE(subtitle, nullptr); + + EXPECT_EQ(video->type(), olive::Track::kVideo); + EXPECT_EQ(audio->type(), olive::Track::kAudio); + EXPECT_EQ(subtitle->type(), olive::Track::kSubtitle); + + EXPECT_EQ(video->track_input(), QStringLiteral("track_in_0")); + EXPECT_EQ(audio->track_input(), QStringLiteral("track_in_1")); + EXPECT_EQ(subtitle->track_input(), QStringLiteral("track_in_2")); + + EXPECT_EQ(video->parent(), &sequence); + EXPECT_EQ(audio->parent(), &sequence); + EXPECT_EQ(subtitle->parent(), &sequence); + + EXPECT_EQ(video->GetTrackCount(), 0); + EXPECT_EQ(video->GetTotalLength(), olive::core::rational(0)); + EXPECT_EQ(video->ArraySize(), 0); + EXPECT_EQ(video->GetTrackAt(0), nullptr); + EXPECT_EQ(video->GetTrackAt(-1), nullptr); + + EXPECT_TRUE(sequence.GetTracks().isEmpty()); + EXPECT_TRUE(sequence.GetUnlockedTracks().isEmpty()); + EXPECT_EQ(sequence.GetTrackFromReference( + olive::Track::Reference(olive::Track::kVideo, 0)), + nullptr); + + // Length verification over empty track lists keeps everything at zero + sequence.VerifyLength(); + EXPECT_EQ(sequence.GetLength(), olive::core::rational(0)); + EXPECT_EQ(sequence.GetVideoLength(), olive::core::rational(0)); + EXPECT_EQ(sequence.GetAudioLength(), olive::core::rational(0)); + EXPECT_EQ(sequence.GetPlayhead(), olive::core::rational(0)); +} + +TEST(Sequence, RetranslateSetsTrackInputNames) +{ + olive::Sequence sequence; + + sequence.Retranslate(); + + EXPECT_EQ(sequence.GetInputName(olive::Sequence::kTrackInputFormat.arg( + olive::Track::kVideo)), + QStringLiteral("Video Tracks")); + EXPECT_EQ(sequence.GetInputName(olive::Sequence::kTrackInputFormat.arg( + olive::Track::kAudio)), + QStringLiteral("Audio Tracks")); + EXPECT_EQ(sequence.GetInputName(olive::Sequence::kTrackInputFormat.arg( + olive::Track::kSubtitle)), + QStringLiteral("Subtitle Tracks")); +} + +TEST(Sequence, AddDefaultNodesCreatesVideoAndAudioTracks) +{ + olive::ColorManager::SetUpDefaultConfig(); + QVector added; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + + QObject::connect(sequence, &olive::Sequence::TrackAdded, + [&added](olive::Track *t) { added.append(t); }); + + sequence->add_default_nodes(); + + olive::TrackList *video_list = sequence->track_list(olive::Track::kVideo); + olive::TrackList *audio_list = sequence->track_list(olive::Track::kAudio); + olive::TrackList *subtitle_list = + sequence->track_list(olive::Track::kSubtitle); + + // One video and one audio track, no subtitle tracks + ASSERT_EQ(video_list->GetTrackCount(), 1); + ASSERT_EQ(audio_list->GetTrackCount(), 1); + EXPECT_EQ(subtitle_list->GetTrackCount(), 0); + + olive::Track *video_track = video_list->GetTrackAt(0); + olive::Track *audio_track = audio_list->GetTrackAt(0); + ASSERT_NE(video_track, nullptr); + ASSERT_NE(audio_track, nullptr); + + EXPECT_EQ(video_track->type(), olive::Track::kVideo); + EXPECT_EQ(audio_track->type(), olive::Track::kAudio); + EXPECT_EQ(video_track->sequence(), sequence); + EXPECT_EQ(audio_track->sequence(), sequence); + EXPECT_EQ(video_track->Index(), 0); + EXPECT_EQ(audio_track->Index(), 0); + + // Both tracks were reparented into the sequence's project + EXPECT_EQ(video_track->parent(), &project); + EXPECT_EQ(audio_track->parent(), &project); + EXPECT_EQ(video_list->GetParentGraph(), &project); + + // The sequence forwards TrackAdded from its track lists + ASSERT_EQ(added.size(), 2); + EXPECT_TRUE(added.contains(video_track)); + EXPECT_TRUE(added.contains(audio_track)); + + // The flattened track cache contains both tracks + EXPECT_EQ(sequence->GetTracks().size(), 2); + EXPECT_TRUE(sequence->GetTracks().contains(video_track)); + EXPECT_TRUE(sequence->GetTracks().contains(audio_track)); + EXPECT_EQ(sequence->GetUnlockedTracks(), sequence->GetTracks()); + + // Track lookup by reference + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kVideo, 0)), + video_track); + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kAudio, 0)), + audio_track); + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kSubtitle, 0)), + nullptr); + + // The default tracks are wired straight into the viewer outputs + EXPECT_TRUE(sequence->IsInputConnected(olive::ViewerOutput::kTextureInput)); + EXPECT_TRUE(sequence->IsInputConnected(olive::ViewerOutput::kSamplesInput)); + EXPECT_EQ(sequence->GetConnectedTextureOutput(), video_track); + EXPECT_EQ(sequence->GetConnectedSampleOutput(), audio_track); +} + +TEST(Sequence, TrackConnectEmitsSignalsAndSetsTrackState) +{ + olive::ColorManager::SetUpDefaultConfig(); + int list_added = 0; + int list_changed = 0; + int sequence_added = 0; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + olive::Track *track = CreateTrack(&project); + + olive::Track *list_last_added = nullptr; + QObject::connect(list, &olive::TrackList::TrackAdded, + [&list_added, &list_last_added](olive::Track *t) { + ++list_added; + list_last_added = t; + }); + QObject::connect(list, &olive::TrackList::TrackListChanged, + [&list_changed]() { ++list_changed; }); + olive::Track *sequence_last_added = nullptr; + QObject::connect(sequence, &olive::Sequence::TrackAdded, + [&sequence_added, &sequence_last_added](olive::Track *t) { + ++sequence_added; + sequence_last_added = t; + }); + + list->ArrayAppend(); + EXPECT_EQ(list->ArraySize(), 1); + EXPECT_EQ(list->GetTrackCount(), 0); + + olive::Node::ConnectEdge(track, list->track_input(0)); + + EXPECT_EQ(list_added, 1); + EXPECT_EQ(list_last_added, track); + EXPECT_EQ(list_changed, 1); + EXPECT_EQ(sequence_added, 1); + EXPECT_EQ(sequence_last_added, track); + + // The track adopts the list's type, sequence and cache index + EXPECT_EQ(track->type(), olive::Track::kVideo); + EXPECT_EQ(track->sequence(), sequence); + EXPECT_EQ(track->Index(), 0); + + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->GetTrackAt(0), track); + EXPECT_EQ(list->GetArrayIndexFromCacheIndex(0), 0); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(0), 0); + EXPECT_EQ(list->GetParentGraph(), &project); + + // TrackList::track_input() builds a NodeInput pointing at the sequence + const olive::NodeInput input = list->track_input(0); + EXPECT_EQ(input, olive::NodeInput( + sequence, olive::Sequence::kTrackInputFormat.arg( + olive::Track::kVideo), + 0)); + + // The sequence-level cache tracks the new track + EXPECT_EQ(sequence->GetTracks(), QVector({ track })); + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kVideo, 0)), + track); +} + +TEST(Sequence, TrackDisconnectResetsTrackState) +{ + olive::ColorManager::SetUpDefaultConfig(); + int list_removed = 0; + int sequence_removed = 0; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *first = CreateTrack(&project); + olive::Track *second = CreateTrack(&project); + AppendTrackToList(list, first); + AppendTrackToList(list, second); + ASSERT_EQ(list->GetTrackCount(), 2); + + olive::Track *list_last_removed = nullptr; + QObject::connect(list, &olive::TrackList::TrackRemoved, + [&list_removed, &list_last_removed](olive::Track *t) { + ++list_removed; + list_last_removed = t; + }); + QObject::connect(sequence, &olive::Sequence::TrackRemoved, + [&sequence_removed](olive::Track *) { + ++sequence_removed; + }); + + olive::Node::DisconnectEdge(first, list->track_input(0)); + + EXPECT_EQ(list_removed, 1); + EXPECT_EQ(list_last_removed, first); + EXPECT_EQ(sequence_removed, 1); + + // The removed track is fully detached from the sequence + EXPECT_EQ(first->sequence(), nullptr); + EXPECT_EQ(first->type(), olive::Track::kNone); + EXPECT_EQ(first->Index(), -1); + + // Subsequent tracks shift down in the cache and get re-indexed + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->GetTrackAt(0), second); + EXPECT_EQ(second->Index(), 0); + + // The array element itself stays; only the cache mapping moves + EXPECT_EQ(list->ArraySize(), 2); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(0), -1); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(1), 0); + EXPECT_EQ(list->GetArrayIndexFromCacheIndex(0), 1); + + EXPECT_EQ(sequence->GetTracks(), QVector({ second })); + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kVideo, 0)), + second); + EXPECT_EQ(sequence->GetTrackFromReference( + olive::Track::Reference(olive::Track::kVideo, 1)), + nullptr); +} + +TEST(TrackList, CacheOrderFollowsArrayIndex) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *first = CreateTrack(&project); + olive::Track *second = CreateTrack(&project); + + list->ArrayAppend(); + list->ArrayAppend(); + + // Connect the higher array element first; it takes cache index 0 for now + olive::Node::ConnectEdge(second, list->track_input(1)); + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->GetTrackAt(0), second); + EXPECT_EQ(second->Index(), 0); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(0), -1); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(1), 0); + + // Connecting element 0 inserts ahead of it in the cache + olive::Node::ConnectEdge(first, list->track_input(0)); + EXPECT_EQ(list->GetTrackCount(), 2); + EXPECT_EQ(list->GetTrackAt(0), first); + EXPECT_EQ(list->GetTrackAt(1), second); + EXPECT_EQ(first->Index(), 0); + EXPECT_EQ(second->Index(), 1); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(0), 0); + EXPECT_EQ(list->GetCacheIndexFromArrayIndex(1), 1); + + // Disconnecting element 0 re-indexes the remainder of the cache + olive::Node::DisconnectEdge(first, list->track_input(0)); + EXPECT_EQ(list->GetTrackCount(), 1); + EXPECT_EQ(list->GetTrackAt(0), second); + EXPECT_EQ(second->Index(), 0); +} + +TEST(TrackList, ArrayAppendAndRemoveLast) +{ + olive::Sequence sequence; + olive::TrackList *list = sequence.track_list(olive::Track::kAudio); + + EXPECT_EQ(list->ArraySize(), 0); + + list->ArrayAppend(); + EXPECT_EQ(list->ArraySize(), 1); + EXPECT_EQ(list->GetTrackCount(), 0); + + list->ArrayAppend(); + EXPECT_EQ(list->ArraySize(), 2); + EXPECT_EQ(list->GetTrackCount(), 0); + + list->ArrayRemoveLast(); + EXPECT_EQ(list->ArraySize(), 1); + + list->ArrayRemoveLast(); + EXPECT_EQ(list->ArraySize(), 0); +} + +TEST(TrackList, NonTrackAndArrayWideConnectionsAreIgnored) +{ + olive::Sequence sequence; + olive::TrackList *list = sequence.track_list(olive::Track::kVideo); + + olive::MathNode math; + olive::Track track; + + int changed = 0; + QObject::connect(list, &olive::TrackList::TrackListChanged, + [&changed]() { ++changed; }); + + // Nodes that are not Tracks never enter the cache + list->TrackConnected(&math, 0); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(changed, 0); + + list->TrackDisconnected(&math, 0); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(changed, 0); + + // Element -1 means the whole array was replaced; the cache is left alone + list->TrackConnected(&track, -1); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(changed, 0); + EXPECT_EQ(track.sequence(), nullptr); + + list->TrackDisconnected(&track, -1); + EXPECT_EQ(list->GetTrackCount(), 0); + EXPECT_EQ(changed, 0); +} + +TEST(Sequence, LengthFlowsFromTracksToLengthCache) +{ + olive::ColorManager::SetUpDefaultConfig(); + QVector lengths; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + + QObject::connect(sequence, &olive::ViewerOutput::LengthChanged, + [&lengths](const olive::core::rational &r) { + lengths.append(r); + }); + + // A video track with content drives the video length and total length + olive::Track *video_track = CreateTrack(&project); + video_track->AppendBlock(CreateClip(&project, olive::core::rational(5))); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), video_track); + + EXPECT_EQ(sequence->GetVideoLength(), olive::core::rational(5)); + EXPECT_EQ(sequence->GetAudioLength(), olive::core::rational(0)); + EXPECT_EQ(sequence->GetLength(), olive::core::rational(5)); + + // A longer audio track takes over the total length + olive::Track *audio_track = CreateTrack(&project); + audio_track->AppendBlock(CreateClip(&project, olive::core::rational(7))); + AppendTrackToList(sequence->track_list(olive::Track::kAudio), audio_track); + + EXPECT_EQ(sequence->GetVideoLength(), olive::core::rational(5)); + EXPECT_EQ(sequence->GetAudioLength(), olive::core::rational(7)); + EXPECT_EQ(sequence->GetLength(), olive::core::rational(7)); + + // Extending a connected track ripples through to the sequence length + video_track->AppendBlock(CreateClip(&project, olive::core::rational(5))); + + EXPECT_EQ(sequence->GetVideoLength(), olive::core::rational(10)); + EXPECT_EQ(sequence->GetAudioLength(), olive::core::rational(7)); + EXPECT_EQ(sequence->GetLength(), olive::core::rational(10)); + + // LengthChanged only fires when the total length actually changes + EXPECT_EQ(lengths, + QVector({ olive::core::rational(5), + olive::core::rational(7), + olive::core::rational(10) })); +} + +TEST(Sequence, SubtitleTrackLengthContributesToTotalLength) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + + olive::Track *subtitle_track = CreateTrack(&project); + subtitle_track->AppendBlock(CreateClip(&project, olive::core::rational(3))); + AppendTrackToList(sequence->track_list(olive::Track::kSubtitle), + subtitle_track); + + EXPECT_EQ(subtitle_track->type(), olive::Track::kSubtitle); + EXPECT_EQ(sequence->GetVideoLength(), olive::core::rational(0)); + EXPECT_EQ(sequence->GetAudioLength(), olive::core::rational(0)); + EXPECT_EQ(sequence->GetLength(), olive::core::rational(3)); +} + +TEST(Sequence, GetUnlockedTracksOmitsLocked) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + + olive::Track *video_a = CreateTrack(&project); + olive::Track *video_b = CreateTrack(&project); + olive::Track *audio = CreateTrack(&project); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), video_a); + AppendTrackToList(sequence->track_list(olive::Track::kVideo), video_b); + AppendTrackToList(sequence->track_list(olive::Track::kAudio), audio); + + // The flattened cache is ordered by track type (video, then audio) + EXPECT_EQ(sequence->GetTracks(), + QVector({ video_a, video_b, audio })); + EXPECT_EQ(sequence->GetUnlockedTracks(), sequence->GetTracks()); + + video_b->SetLocked(true); + EXPECT_EQ(sequence->GetUnlockedTracks(), + QVector({ video_a, audio })); + + video_b->SetLocked(false); + EXPECT_EQ(sequence->GetUnlockedTracks(), sequence->GetTracks()); +} + +TEST(Sequence, SubtitleInvalidateEmitsSubtitlesChanged) +{ + olive::ColorManager::SetUpDefaultConfig(); + QVector received; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + + QObject::connect(sequence, &olive::Sequence::SubtitlesChanged, + [&received](const olive::core::TimeRange &r) { + received.append(r); + }); + + // Invalidations from the subtitle track input are forwarded as a signal + // (Sequence's override hides the base-class default arguments, so the + // element and options must be passed explicitly) + const olive::core::TimeRange subtitle_range(olive::core::rational(1), + olive::core::rational(2)); + sequence->InvalidateCache(subtitle_range, + olive::Sequence::kTrackInputFormat.arg( + olive::Track::kSubtitle), + -1, olive::Node::InvalidateCacheOptions()); + + EXPECT_EQ(received, QVector({ subtitle_range })); + + // Invalidations from other inputs do not emit the signal + sequence->InvalidateCache( + olive::core::TimeRange(olive::core::rational(3), + olive::core::rational(4)), + olive::Sequence::kTrackInputFormat.arg(olive::Track::kVideo), -1, + olive::Node::InvalidateCacheOptions()); + sequence->InvalidateCache( + olive::core::TimeRange(olive::core::rational(3), + olive::core::rational(4)), + olive::ViewerOutput::kTextureInput, -1, + olive::Node::InvalidateCacheOptions()); + + EXPECT_EQ(received.size(), 1); +} + +TEST(Sequence, TrackHeightChangePropagatesThroughTrackList) +{ + olive::ColorManager::SetUpDefaultConfig(); + int emissions = 0; + int signal_height = 0; + olive::Project project; + project.Initialize(); + olive::Sequence *sequence = CreateSequence(&project); + olive::TrackList *list = sequence->track_list(olive::Track::kVideo); + + olive::Track *track = CreateTrack(&project); + AppendTrackToList(list, track); + + olive::Track *signal_track = nullptr; + QObject::connect(list, &olive::TrackList::TrackHeightChanged, + [&emissions, &signal_track, &signal_height]( + olive::Track *t, int h) { + ++emissions; + signal_track = t; + signal_height = h; + }); + + track->SetTrackHeightInPixels(96); + + EXPECT_EQ(emissions, 1); + EXPECT_EQ(signal_track, track); + EXPECT_EQ(signal_height, 96); +}