diff --git a/app/render/audioplaybackcache.cpp b/app/render/audioplaybackcache.cpp index a02c023ec..173a9ad3c 100644 --- a/app/render/audioplaybackcache.cpp +++ b/app/render/audioplaybackcache.cpp @@ -99,7 +99,9 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples, int64_t segment_end = segment_start + kDefaultSegmentSizePerChannel; int64_t offset_in_segment = current_cache_offset - segment_start; - int64_t write_len = segment_end - offset_in_segment; + // Never write past the end of the requested range + int64_t write_len = std::min(segment_end - current_cache_offset, + end_cache_offset - current_cache_offset); int64_t max_buffer_len = end_buffer_offset - current_buffer_offset; int64_t zero_len = 0; @@ -119,13 +121,17 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples, QFile f(filename); if (f.open(QFile::ReadWrite)) { f.seek(offset_in_segment); - f.write(reinterpret_cast(samples.data(channel)) + - current_buffer_offset, - write_len); + if (write_len > 0) { + f.write(reinterpret_cast(samples.data(channel)) + + current_buffer_offset, + write_len); + } if (zero_len > 0) { + // NOTE: the length must be passed explicitly; write(const + // char*) would treat the zeros as an empty C string QByteArray b(zero_len, 0); - f.write(b.constData()); + f.write(b.constData(), b.size()); } f.close(); @@ -134,7 +140,7 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples, } } - current_cache_offset += write_len; + current_cache_offset += write_len + zero_len; current_buffer_offset += write_len; } diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index 2c949d8fa..7381205e7 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -75,6 +75,10 @@ add_executable(olive-gtest node_distort_test.cpp node_filter_keying_test.cpp node_math_transition_test.cpp + node_save_load_test.cpp + node_polygon_folder_test.cpp + footage_probe_test.cpp + render_tail_test.cpp timeline_marker_test.cpp undo_stack_test.cpp plugin_support_test.cpp diff --git a/tests/gtest/footage_probe_test.cpp b/tests/gtest/footage_probe_test.cpp new file mode 100644 index 000000000..fbbd64e0b --- /dev/null +++ b/tests/gtest/footage_probe_test.cpp @@ -0,0 +1,486 @@ +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "codec/decoder.h" +#include "common/filefunctions.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/globals.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/footage/footagedescription.h" +#include "render/diskmanager.h" +#include "render/job/footagejob.h" +#include "render/loopmode.h" +#include "render/texture.h" + +namespace +{ + +QString DemoVideoPath() +{ + return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/demo.mp4")); +} + +QString TestImagePath() +{ + return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/img.png")); +} + +// Mirrors the cache path expression used by Footage::Reprobe() +QString MetadataCacheFileFor(const QString &media_path) +{ + return QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation)) + .filePath(olive::FileFunctions::GetUniqueFileIdentifier(media_path)); +} + +} // namespace + +TEST(FootageProbe, FFmpegProbeOfDemoMp4ReportsExpectedStreams) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::DecoderPtr decoder = + olive::Decoder::CreateFromID(QStringLiteral("ffmpeg")); + ASSERT_TRUE(decoder); + + const olive::FootageDescription desc = decoder->Probe(path, nullptr); + ASSERT_TRUE(desc.IsValid()); + EXPECT_EQ(desc.decoder(), QStringLiteral("ffmpeg")); + + // The file holds video + audio + a timecode data track. The data track is + // counted in the total but not exposed as a usable stream. + ASSERT_EQ(desc.GetVideoStreams().size(), 1); + ASSERT_EQ(desc.GetAudioStreams().size(), 1); + EXPECT_EQ(desc.GetSubtitleStreams().size(), 0); + EXPECT_EQ(desc.GetStreamCount(), 3); + + const olive::VideoParams &video = desc.GetVideoStreams().first(); + EXPECT_EQ(video.stream_index(), 0); + EXPECT_EQ(video.width(), 1920); + EXPECT_EQ(video.height(), 1080); + EXPECT_EQ(video.video_type(), olive::VideoParams::kVideoTypeVideo); + EXPECT_EQ(video.interlacing(), olive::VideoParams::kInterlaceNone); + EXPECT_EQ(video.pixel_aspect_ratio(), olive::rational(1, 1)); + EXPECT_EQ(video.frame_rate(), olive::rational(25)); + EXPECT_EQ(video.time_base(), olive::rational(1, 12800)); + EXPECT_EQ(video.duration(), 217600); // 17 seconds at 1/12800 + EXPECT_NE(video.format(), olive::core::PixelFormat::INVALID); + EXPECT_GT(video.channel_count(), 0); + + const olive::core::AudioParams &audio = desc.GetAudioStreams().first(); + EXPECT_EQ(audio.stream_index(), 1); + EXPECT_EQ(audio.sample_rate(), 48000); + EXPECT_EQ(audio.channel_count(), 2); + EXPECT_EQ(audio.time_base(), olive::rational(1, 48000)); + EXPECT_EQ(audio.duration(), 816000); // 17 seconds at 1/48000 + + // The file's timecode track starts at 01:00:00:00 + ASSERT_TRUE(desc.HasSourceStartTime()); + EXPECT_EQ(desc.source_start_time(), olive::rational(3600)); + EXPECT_EQ(desc.source_start_time_source(), QStringLiteral("timecode")); +} + +TEST(FootageProbe, ProbeOfUnprobeableFileYieldsInvalidDescription) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + const QString path = + QDir(dir.path()).filePath(QStringLiteral("not_media.txt")); + { + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("this is not a media file"); + } + + for (const olive::DecoderPtr &decoder : + olive::Decoder::ReceiveListOfAllDecoders()) { + EXPECT_FALSE(decoder->Probe(path, nullptr).IsValid()) + << decoder->id().toStdString(); + } +} + +class FootageProbeTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!olive::Core::instance()) { + // Leaked intentionally: Core is process-wide (matches footage_test) + new olive::Core(olive::Core::CoreParams()); + } + + // Footage::Value() resolves Project::cache_path(), which goes through + // the DiskManager singleton + created_disk_manager_ = (olive::DiskManager::instance() == nullptr); + if (created_disk_manager_) { + olive::DiskManager::CreateInstance(); + } + + // Sandbox the footage metadata cache so real probes write into the + // temp dir instead of the user's cache + old_cache_home_ = qgetenv("XDG_CACHE_HOME"); + had_cache_home_ = qEnvironmentVariableIsSet("XDG_CACHE_HOME"); + qputenv("XDG_CACHE_HOME", + QDir(temp_dir_.path()).filePath(QStringLiteral("xdg")).toUtf8()); + QDir().mkpath( + QStandardPaths::writableLocation(QStandardPaths::CacheLocation)); + + olive::ColorManager::SetUpDefaultConfig(); + + project_ = std::make_unique(); + project_->Initialize(); + } + + void TearDown() override + { + project_.reset(); + if (created_disk_manager_) { + olive::DiskManager::DestroyInstance(); + } + if (had_cache_home_) { + qputenv("XDG_CACHE_HOME", old_cache_home_); + } else { + qunsetenv("XDG_CACHE_HOME"); + } + } + + // Constructs a Footage pointing at path; the constructor's set_filename() + // call probes the file synchronously before the node joins the graph + olive::Footage *AddProbedFootage(const QString &path) + { + auto *footage = new olive::Footage(path); + footage->setParent(project_.get()); + return footage; + } + + QTemporaryDir temp_dir_; + QByteArray old_cache_home_; + bool had_cache_home_ = false; + bool created_disk_manager_ = false; + std::unique_ptr project_; +}; + +TEST_F(FootageProbeTest, ProbingDemoMp4PopulatesFootageState) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *footage = AddProbedFootage(path); + + EXPECT_TRUE(footage->IsValid()); + EXPECT_EQ(footage->decoder(), QStringLiteral("ffmpeg")); + EXPECT_EQ(footage->timestamp(), + QFileInfo(path).lastModified().toMSecsSinceEpoch()); + + // Video + audio streams are usable; the timecode data track only shows up + // in the total stream count + EXPECT_EQ(footage->GetTotalStreamCount(), 3); + EXPECT_EQ(footage->GetVideoStreamCount(), 1); + EXPECT_EQ(footage->GetAudioStreamCount(), 1); + EXPECT_EQ(footage->GetSubtitleStreamCount(), 0); + + EXPECT_EQ(footage->GetStreamIndex(olive::Track::kVideo, 0), 0); + EXPECT_EQ(footage->GetStreamIndex(olive::Track::kAudio, 0), 1); + EXPECT_EQ(footage->GetReferenceFromRealIndex(0), + olive::Track::Reference(olive::Track::kVideo, 0)); + EXPECT_EQ(footage->GetReferenceFromRealIndex(1), + olive::Track::Reference(olive::Track::kAudio, 0)); + EXPECT_EQ(footage->GetReferenceFromRealIndex(2).type(), + olive::Track::kNone); + + EXPECT_EQ(footage->GetConnectedTextureOutput(), + static_cast(footage)); + EXPECT_EQ(footage->GetConnectedSampleOutput(), + static_cast(footage)); + + const olive::VideoParams video = footage->GetVideoParams(0); + ASSERT_TRUE(video.is_valid()); + EXPECT_EQ(video.stream_index(), 0); + EXPECT_EQ(video.width(), 1920); + EXPECT_EQ(video.height(), 1080); + EXPECT_EQ(video.video_type(), olive::VideoParams::kVideoTypeVideo); + EXPECT_EQ(video.frame_rate(), olive::rational(25)); + EXPECT_EQ(video.time_base(), olive::rational(1, 12800)); + EXPECT_EQ(video.duration(), 217600); + EXPECT_EQ(video.color_range(), olive::VideoParams::kColorRangeLimited); + EXPECT_TRUE(video.enabled()); + // The FFmpeg probe leaves colorspace unset so the project default applies + EXPECT_TRUE(video.colorspace().isEmpty()); + + const olive::core::AudioParams audio = footage->GetAudioParams(0); + ASSERT_TRUE(audio.is_valid()); + EXPECT_EQ(audio.stream_index(), 1); + EXPECT_EQ(audio.sample_rate(), 48000); + EXPECT_EQ(audio.channel_count(), 2); + EXPECT_EQ(audio.duration(), 816000); + EXPECT_TRUE(audio.enabled()); +} + +TEST_F(FootageProbeTest, ProbingDemoMp4SetsLengthsAndSourceStartTime) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *footage = AddProbedFootage(path); + footage->VerifyLength(); + + // Both streams describe 17 seconds of media + EXPECT_EQ(footage->GetVideoLength(), olive::rational(17)); + EXPECT_EQ(footage->GetAudioLength(), olive::rational(17)); + EXPECT_EQ(footage->GetLength(), olive::rational(17)); + + // The embedded 01:00:00:00 timecode becomes the source start time + ASSERT_TRUE(footage->HasSourceStartTime()); + EXPECT_EQ(footage->source_start_time(), olive::rational(3600)); + EXPECT_EQ(footage->source_start_time_source(), QStringLiteral("timecode")); +} + +TEST_F(FootageProbeTest, ProbingPngImageProducesSingleStillStream) +{ + const QString path = TestImagePath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *footage = AddProbedFootage(path); + + EXPECT_TRUE(footage->IsValid()); + // Still images are handled by the OIIO decoder, which probes before FFmpeg + EXPECT_EQ(footage->decoder(), QStringLiteral("oiio")); + + EXPECT_EQ(footage->GetTotalStreamCount(), 1); + EXPECT_EQ(footage->GetVideoStreamCount(), 1); + EXPECT_EQ(footage->GetAudioStreamCount(), 0); + EXPECT_EQ(footage->GetSubtitleStreamCount(), 0); + + const olive::VideoParams still = footage->GetVideoParams(0); + ASSERT_TRUE(still.is_valid()); + EXPECT_EQ(still.stream_index(), 0); + EXPECT_EQ(still.width(), 1920); + EXPECT_EQ(still.height(), 1080); + EXPECT_EQ(still.video_type(), olive::VideoParams::kVideoTypeStill); + EXPECT_EQ(still.channel_count(), 4); + EXPECT_EQ(still.format(), olive::core::PixelFormat::U8); + EXPECT_TRUE(still.premultiplied_alpha()); + EXPECT_TRUE(still.enabled()); + EXPECT_TRUE(still.colorspace().isEmpty()); + + // Stills have no duration and no source start time + footage->VerifyLength(); + EXPECT_EQ(footage->GetVideoLength(), olive::rational(0)); + EXPECT_EQ(footage->GetLength(), olive::rational(0)); + EXPECT_FALSE(footage->HasSourceStartTime()); + + EXPECT_EQ(footage->GetConnectedTextureOutput(), + static_cast(footage)); + EXPECT_EQ(footage->GetConnectedSampleOutput(), nullptr); +} + +TEST_F(FootageProbeTest, ProbedFootageValuePushesRealStreamJobs) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *footage = AddProbedFootage(path); + footage->VerifyLength(); + + // The colorspace fallback reads the project default, and the audio cache + // path comes from the project's cache settings + project_->SetDefaultInputColorSpace(QStringLiteral("ProbeInputSpace")); + project_->SetCacheLocationSetting(olive::Project::kCacheCustomPath); + const QString cache_path = + QDir(temp_dir_.path()).filePath(QStringLiteral("cache")); + project_->SetCustomCachePath(cache_path); + + olive::NodeValueRow row; + row.insert(olive::Footage::kFilenameInput, + olive::NodeValue(olive::NodeValue::kFile, path)); + + olive::VideoParams vparams(64, 64, olive::rational(1, 24), + olive::core::PixelFormat::U8, 4); + const olive::NodeGlobals globals(vparams, olive::core::AudioParams(), + olive::rational(0), + olive::LoopMode::kLoopModeOff); + + olive::NodeValueTable table; + footage->Value(row, globals, &table); + + // Length, one texture job for the video stream, one sample job for the + // audio stream; the timecode data track produces no job + ASSERT_EQ(table.Count(), 3); + + const olive::NodeValue length = + table.Get(olive::NodeValue::kRational, QStringLiteral("length")); + ASSERT_EQ(length.type(), olive::NodeValue::kRational); + EXPECT_EQ(length.toRational(), olive::rational(17)); + + const olive::TexturePtr texture = + table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0")) + .toTexture(); + ASSERT_NE(texture, nullptr); + EXPECT_EQ(texture->params().width(), 1920); + EXPECT_EQ(texture->params().height(), 1080); + // The probed stream has no colorspace, so the project default is used + EXPECT_EQ(texture->params().colorspace(), + QStringLiteral("ProbeInputSpace")); + + const auto *video_job = + static_cast(texture->job()); + ASSERT_NE(video_job, nullptr); + EXPECT_EQ(video_job->decoder(), QStringLiteral("ffmpeg")); + EXPECT_EQ(video_job->filename(), path); + EXPECT_EQ(video_job->type(), olive::Track::kVideo); + EXPECT_EQ(video_job->length(), olive::rational(17)); + + const olive::FootageJob audio_job = + table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0")) + .data() + .value(); + EXPECT_EQ(audio_job.decoder(), QStringLiteral("ffmpeg")); + EXPECT_EQ(audio_job.filename(), path); + EXPECT_EQ(audio_job.type(), olive::Track::kAudio); + EXPECT_EQ(audio_job.audio_params().sample_rate(), 48000); + EXPECT_EQ(audio_job.length(), olive::rational(17)); + EXPECT_EQ(audio_job.cache_path(), cache_path); +} + +TEST_F(FootageProbeTest, SecondProbeReadsBackMetadataCache) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *first = AddProbedFootage(path); + ASSERT_TRUE(first->IsValid()); + + // The first probe writes a stream metadata cache into the cache location + const QString cache_file = MetadataCacheFileFor(path); + ASSERT_TRUE(QFileInfo::exists(cache_file)); + + // A second footage for the same file loads its metadata from that cache + // and ends up with identical state + olive::Footage *second = AddProbedFootage(path); + ASSERT_TRUE(second->IsValid()); + EXPECT_EQ(second->decoder(), first->decoder()); + EXPECT_EQ(second->GetTotalStreamCount(), first->GetTotalStreamCount()); + EXPECT_EQ(second->GetVideoStreamCount(), first->GetVideoStreamCount()); + EXPECT_EQ(second->GetAudioStreamCount(), first->GetAudioStreamCount()); + + const olive::VideoParams from_cache = second->GetVideoParams(0); + const olive::VideoParams probed = first->GetVideoParams(0); + EXPECT_EQ(from_cache.stream_index(), probed.stream_index()); + EXPECT_EQ(from_cache.width(), probed.width()); + EXPECT_EQ(from_cache.height(), probed.height()); + EXPECT_EQ(from_cache.frame_rate(), probed.frame_rate()); + EXPECT_EQ(from_cache.time_base(), probed.time_base()); + EXPECT_EQ(from_cache.duration(), probed.duration()); + EXPECT_EQ(from_cache.video_type(), probed.video_type()); + + ASSERT_TRUE(second->HasSourceStartTime()); + EXPECT_EQ(second->source_start_time(), olive::rational(3600)); + EXPECT_EQ(second->source_start_time_source(), QStringLiteral("timecode")); +} + +TEST_F(FootageProbeTest, FilenameChangeToMissingFileClearsProbeState) +{ + const QString path = DemoVideoPath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + olive::Footage *footage = AddProbedFootage(path); + ASSERT_TRUE(footage->IsValid()); + ASSERT_GT(footage->GetTotalStreamCount(), 0); + + // Pointing the footage at a nonexistent file clears the probed state and + // the re-probe fails + footage->set_filename( + QDir(temp_dir_.path()).filePath(QStringLiteral("gone.mp4"))); + + EXPECT_FALSE(footage->IsValid()); + EXPECT_EQ(footage->GetTotalStreamCount(), 0); + EXPECT_EQ(footage->GetVideoStreamCount(), 0); + EXPECT_EQ(footage->GetAudioStreamCount(), 0); + EXPECT_TRUE(footage->decoder().isEmpty()); + EXPECT_EQ(footage->timestamp(), 0); + EXPECT_FALSE(footage->HasSourceStartTime()); +} + +TEST_F(FootageProbeTest, CheckFootageOnlyRespondsWithActiveWindow) +{ + const QString path = TestImagePath(); + ASSERT_TRUE(QFileInfo::exists(path)); + + // Work on a copy so the original test asset is untouched + const QString copy = + QDir(temp_dir_.path()).filePath(QStringLiteral("image.png")); + ASSERT_TRUE(QFile::copy(path, copy)); + + olive::Footage *footage = AddProbedFootage(copy); + ASSERT_TRUE(footage->IsValid()); + const qint64 probed_timestamp = footage->timestamp(); + ASSERT_GT(probed_timestamp, 0); + + // The file vanishes behind the footage's back + ASSERT_TRUE(QFile::remove(copy)); + + // Without an active window, CheckFootage is a no-op + ASSERT_TRUE( + QMetaObject::invokeMethod(footage, "CheckFootage", Qt::DirectConnection)); + EXPECT_EQ(footage->timestamp(), probed_timestamp); + EXPECT_TRUE(footage->IsValid()); + + // With an active window, CheckFootage notices the missing file and + // re-probes. The re-probe resets the timestamp but, because Reprobe() + // never clears existing state for a missing file, the (now stale) probe + // data is kept until the filename itself changes. + { + QWidget window; + window.show(); + window.activateWindow(); + QCoreApplication::processEvents(); + ASSERT_EQ(qApp->activeWindow(), &window); + + ASSERT_TRUE(QMetaObject::invokeMethod(footage, "CheckFootage", + Qt::DirectConnection)); + } + ASSERT_EQ(qApp->activeWindow(), nullptr); + + EXPECT_EQ(footage->timestamp(), 0); + EXPECT_TRUE(footage->IsValid()); + EXPECT_EQ(footage->GetVideoStreamCount(), 1); +} + +TEST_F(FootageProbeTest, ProbingExistingButInvalidMediaStaysInvalid) +{ + const QString path = + QDir(temp_dir_.path()).filePath(QStringLiteral("fake.mkv")); + { + QFile file(path); + ASSERT_TRUE(file.open(QFile::WriteOnly)); + file.write("OAK_FAKE_MEDIA"); + } + + olive::Footage *footage = AddProbedFootage(path); + + // The file exists but no decoder can probe it, so the footage stays + // invalid + EXPECT_FALSE(footage->IsValid()); + EXPECT_TRUE(footage->decoder().isEmpty()); + EXPECT_EQ(footage->GetTotalStreamCount(), 0); + EXPECT_EQ(footage->GetVideoStreamCount(), 0); + EXPECT_EQ(footage->GetAudioStreamCount(), 0); + EXPECT_EQ(footage->GetSubtitleStreamCount(), 0); + + // Note: Reprobe caches even this failed probe result, so future reprobes + // of the same path reload the invalid description instead of re-probing + EXPECT_TRUE(QFileInfo::exists(MetadataCacheFileFor(path))); +} diff --git a/tests/gtest/node_polygon_folder_test.cpp b/tests/gtest/node_polygon_folder_test.cpp new file mode 100644 index 000000000..34c571573 --- /dev/null +++ b/tests/gtest/node_polygon_folder_test.cpp @@ -0,0 +1,1056 @@ +#include + +#include +#include +#include +#include +#include + +#include "core.h" +#include "codec/frame.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/generator/polygon/polygon.h" +#include "node/generator/shape/generatorwithmerge.h" +#include "node/generator/shape/shapenodebase.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv1.h" +#include "node/generator/text/textv2.h" +#include "node/gizmo/line.h" +#include "node/gizmo/path.h" +#include "node/gizmo/point.h" +#include "node/globals.h" +#include "node/nodeundo.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/project/serializer/serializer.h" +#include "node/traverser.h" +#include "olive/core/util/color.h" +#include "render/diskmanager.h" +#include "render/job/generatejob.h" +#include "render/loopmode.h" +#include "render/texture.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)); +} + +// A fresh traverser per call: NodeTraverser caches tables per node/range, so +// reusing one would return stale results after changing standard values. +olive::NodeValueTable GenerateTable(const olive::Node *node, + const olive::VideoParams &vparams) +{ + olive::NodeTraverser traverser; + traverser.SetCacheVideoParams(vparams); + return traverser.GenerateTable(node, FirstFrame()); +} + +olive::NodeValueRow GenerateRow(const olive::Node *node) +{ + olive::NodeTraverser traverser; + return traverser.GenerateRow(node, FirstFrame()); +} + +olive::TexturePtr GetOutputTexture(const olive::NodeValueTable &table) +{ + return table.Get(olive::NodeValue::kTexture).toTexture(); +} + +// Project save/load touches the DiskManager singleton, which itself touches Core +void EnsureAppSingletons() +{ + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } +} + +} // namespace + +TEST(Folder, MetadataAndChildInputDefinition) +{ + olive::Folder folder; + EXPECT_EQ(folder.id(), QStringLiteral("org.olivevideoeditor.Olive.folder")); + EXPECT_EQ(folder.Name(), QStringLiteral("Folder")); + EXPECT_FALSE(folder.Description().isEmpty()); + EXPECT_TRUE(folder.Category().contains(olive::Node::kCategoryProject)); + EXPECT_TRUE(folder.IsItem()); + + // The child input is a non-keyframable array that accepts any node + EXPECT_TRUE(folder.HasInputWithID(olive::Folder::kChildInput)); + EXPECT_TRUE(folder.InputIsArray(olive::Folder::kChildInput)); + EXPECT_EQ(int(folder.GetInputDataType(olive::Folder::kChildInput)), + int(olive::NodeValue::kNone)); + EXPECT_FALSE(folder.IsInputKeyframable(olive::Folder::kChildInput)); + + // Folders provide their own icon; every other data type falls through to + // the Node base implementation + EXPECT_TRUE(folder.data(olive::Node::ICON).isValid()); + EXPECT_FALSE(folder.data(olive::Node::TOOLTIP).isValid()); +} + +TEST(Folder, RetranslateSetsChildInputName) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *folder = AddNode(&project); + folder->Retranslate(); + + EXPECT_EQ(folder->GetInputName(olive::Folder::kChildInput), + QStringLiteral("Children")); +} + +TEST(Folder, AddChildAppendsAndEmitsSignals) +{ + // Declared before the project so teardown signals never outlive them + QVector inserted_items; + QVector inserted_indices; + int insert_ends = 0; + + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *child = AddNode(&project); + + QObject::connect(folder, &olive::Folder::BeginInsertItem, + [&inserted_items, &inserted_indices](olive::Node *n, + int index) { + inserted_items.append(n); + inserted_indices.append(index); + }); + QObject::connect(folder, &olive::Folder::EndInsertItem, + [&insert_ends]() { ++insert_ends; }); + + olive::FolderAddChild(folder, child).redo_now(); + + ASSERT_EQ(folder->item_child_count(), 1); + EXPECT_EQ(folder->item_child(0), child); + EXPECT_EQ(folder->children().first(), child); + EXPECT_EQ(folder->index_of_child(child), 0); + EXPECT_EQ(folder->index_of_child_in_array(child), 0); + EXPECT_EQ(child->folder(), folder); + + // The insert index is always the append position: the internal model only + // ever appends, sorting is left to a proxy model (see folder.cpp) + ASSERT_EQ(inserted_items.size(), 1); + EXPECT_EQ(inserted_items.first(), child); + EXPECT_EQ(inserted_indices.first(), 0); + EXPECT_EQ(insert_ends, 1); +} + +TEST(Folder, AddChildUndoRemovesChildAndEmitsSignals) +{ + // Declared before the project so teardown signals never outlive them + QVector removed_items; + QVector removed_indices; + int remove_ends = 0; + + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *child = AddNode(&project); + + QObject::connect(folder, &olive::Folder::BeginRemoveItem, + [&removed_items, &removed_indices](olive::Node *n, + int index) { + removed_items.append(n); + removed_indices.append(index); + }); + QObject::connect(folder, &olive::Folder::EndRemoveItem, + [&remove_ends]() { ++remove_ends; }); + + olive::FolderAddChild add(folder, child); + add.redo_now(); + ASSERT_EQ(folder->item_child_count(), 1); + + add.undo_now(); + + EXPECT_EQ(folder->item_child_count(), 0); + EXPECT_EQ(folder->index_of_child(child), -1); + EXPECT_EQ(folder->index_of_child_in_array(child), -1); + EXPECT_EQ(child->folder(), nullptr); + + ASSERT_EQ(removed_items.size(), 1); + EXPECT_EQ(removed_items.first(), child); + EXPECT_EQ(removed_indices.first(), 0); + EXPECT_EQ(remove_ends, 1); +} + +TEST(Folder, RemoveElementCommandRemovesAndRestores) +{ + // Declared before the project so teardown signals never outlive them + QVector removed_items; + QVector removed_indices; + + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *first = AddNode(&project); + auto *second = AddNode(&project); + olive::FolderAddChild(folder, first).redo_now(); + olive::FolderAddChild(folder, second).redo_now(); + ASSERT_EQ(folder->item_child_count(), 2); + + QObject::connect(folder, &olive::Folder::BeginRemoveItem, + [&removed_items, &removed_indices](olive::Node *n, + int index) { + removed_items.append(n); + removed_indices.append(index); + }); + + olive::Folder::RemoveElementCommand remove(folder, first); + remove.redo_now(); + + EXPECT_EQ(folder->item_child_count(), 1); + EXPECT_EQ(folder->item_child(0), second); + EXPECT_EQ(first->folder(), nullptr); + // RemoveElementCommand removes the edge and the array element as separate + // subcommands, each of which fires BeginRemoveItem + ASSERT_EQ(removed_items.size(), 2); + EXPECT_EQ(removed_items.first(), first); + EXPECT_EQ(removed_indices.first(), 0); + + remove.undo_now(); + + // The connection is restored at its original array element, but + // Folder::InputConnectedEvent only ever appends to the internal model, so + // the restored child lands at the end of the children list + ASSERT_EQ(folder->item_child_count(), 2); + EXPECT_EQ(folder->item_child(0), second); + EXPECT_EQ(folder->item_child(1), first); + EXPECT_EQ(folder->index_of_child(first), 1); + EXPECT_EQ(folder->index_of_child_in_array(first), 0); + EXPECT_EQ(first->folder(), folder); +} + +TEST(Folder, RemoveElementCommandIgnoresForeignChild) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *child = AddNode(&project); + auto *stranger = AddNode(&project); + olive::FolderAddChild(folder, child).redo_now(); + ASSERT_EQ(folder->item_child_count(), 1); + + // A node that was never added has no array element, so the command is a + // no-op rather than an error + olive::Folder::RemoveElementCommand remove(folder, stranger); + remove.redo_now(); + EXPECT_EQ(folder->item_child_count(), 1); + EXPECT_EQ(folder->item_child(0), child); + + remove.undo_now(); + EXPECT_EQ(folder->item_child_count(), 1); +} + +TEST(Folder, GetChildWithNameFindsNestedChildren) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *sub = AddNode(&project); + sub->SetLabel(QStringLiteral("Sub")); + auto *nested = AddNode(&project); + nested->SetLabel(QStringLiteral("Nested")); + + olive::FolderAddChild(folder, sub).redo_now(); + olive::FolderAddChild(sub, nested).redo_now(); + + // Lookup by label recurses into subfolders + EXPECT_EQ(folder->GetChildWithName(QStringLiteral("Sub")), sub); + EXPECT_EQ(folder->GetChildWithName(QStringLiteral("Nested")), nested); + EXPECT_TRUE(folder->ChildExistsWithName(QStringLiteral("Nested"))); + + EXPECT_EQ(folder->GetChildWithName(QStringLiteral("Missing")), nullptr); + EXPECT_FALSE(folder->ChildExistsWithName(QStringLiteral("Missing"))); +} + +TEST(Folder, HasChildRecursiveFindsNestedChildren) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *sub = AddNode(&project); + auto *nested = AddNode(&project); + auto *outsider = AddNode(&project); + + olive::FolderAddChild(folder, sub).redo_now(); + olive::FolderAddChild(sub, nested).redo_now(); + + EXPECT_TRUE(folder->HasChildRecursive(sub)); + EXPECT_TRUE(folder->HasChildRecursive(nested)); + EXPECT_FALSE(folder->HasChildRecursive(outsider)); + EXPECT_FALSE(folder->HasChildRecursive(folder)); +} + +TEST(Folder, ListChildrenOfTypeRecursesIntoSubfolders) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + olive::Folder *folder = project.root(); + auto *sub = AddNode(&project); + auto *nested = AddNode(&project); + auto *item = AddNode(&project); + + olive::FolderAddChild(folder, sub).redo_now(); + olive::FolderAddChild(sub, nested).redo_now(); + olive::FolderAddChild(folder, item).redo_now(); + + // Folders are collected recursively while other items are skipped + const QVector folders = + folder->ListChildrenOfType(); + ASSERT_EQ(folders.size(), 2); + EXPECT_TRUE(folders.contains(sub)); + EXPECT_TRUE(folders.contains(nested)); + EXPECT_FALSE(folders.contains(static_cast(item))); + + const QVector solids = + folder->ListChildrenOfType(); + ASSERT_EQ(solids.size(), 1); + EXPECT_EQ(solids.first(), item); +} + +TEST(Folder, ChildStructureSurvivesSerializationRoundTrip) +{ + EnsureAppSingletons(); + olive::ColorManager::SetUpDefaultConfig(); + olive::NodeFactory::Initialize(); + // Guard against serializer instances left over by another test + olive::ProjectSerializer::Destroy(); + olive::ProjectSerializer::Initialize(); + + olive::Project project; + project.Initialize(); + + auto *sub = AddNode(&project); + sub->SetLabel(QStringLiteral("Sub")); + olive::FolderAddChild(project.root(), sub).redo_now(); + + auto *nested = AddNode(&project); + nested->SetLabel(QStringLiteral("Nested")); + olive::FolderAddChild(sub, nested).redo_now(); + + olive::ProjectSerializer::SaveData save_data( + olive::ProjectSerializer::kProject, &project, QString()); + + QByteArray xml; + QBuffer buffer(&xml); + buffer.open(QIODevice::WriteOnly); + QXmlStreamWriter writer(&buffer); + ASSERT_EQ(olive::ProjectSerializer::Save(&writer, save_data).code(), + olive::ProjectSerializer::kSuccess); + buffer.close(); + + olive::Project loaded_project; + QBuffer read_buffer(&xml); + read_buffer.open(QIODevice::ReadOnly); + QXmlStreamReader reader(&read_buffer); + olive::ProjectSerializer::Result result = olive::ProjectSerializer::Load( + &loaded_project, &reader, olive::ProjectSerializer::kProject); + ASSERT_EQ(result.code(), olive::ProjectSerializer::kSuccess); + + // The child connections of kChildInput are re-established on load, + // rebuilding the folder hierarchy + olive::Folder *loaded_root = loaded_project.root(); + ASSERT_NE(loaded_root, nullptr); + ASSERT_EQ(loaded_root->item_child_count(), 1); + + olive::Node *loaded_sub = loaded_root->item_child(0); + EXPECT_EQ(loaded_sub->GetLabel(), QStringLiteral("Sub")); + EXPECT_EQ(loaded_sub->folder(), loaded_root); + + auto *loaded_sub_folder = dynamic_cast(loaded_sub); + ASSERT_NE(loaded_sub_folder, nullptr); + ASSERT_EQ(loaded_sub_folder->item_child_count(), 1); + EXPECT_EQ(loaded_sub_folder->item_child(0)->GetLabel(), + QStringLiteral("Nested")); + EXPECT_EQ(loaded_sub_folder->item_child(0)->folder(), loaded_sub_folder); + + EXPECT_EQ(loaded_root->GetChildWithName(QStringLiteral("Nested")), + loaded_sub_folder->item_child(0)); + EXPECT_TRUE( + loaded_root->HasChildRecursive(loaded_sub_folder->item_child(0))); + + olive::ProjectSerializer::Destroy(); +} + +TEST(PolygonGenerator, GenerateFrameRasterizesDefaultPentagon) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params(vparams); + frame->allocate(); + + node->GenerateFrame(frame, olive::GenerateJob(GenerateRow(node))); + + auto pixel = [&frame](int x, int y) -> const uchar * { + return reinterpret_cast(frame->data()) + + y * frame->linesize_bytes() + + x * olive::VideoParams::kRGBAChannelCount; + }; + + // The pentagon is filled white: the frame center is well inside it + const uchar *center = pixel(160, 120); + EXPECT_EQ(int(center[0]), 255); + EXPECT_EQ(int(center[1]), 255); + EXPECT_EQ(int(center[2]), 255); + EXPECT_EQ(int(center[3]), 255); + + const uchar *lower = pixel(160, 200); + EXPECT_EQ(int(lower[3]), 255); + + // The corners are outside the pentagon and stay transparent + for (int y : { 0, 239 }) { + for (int x : { 0, 319 }) { + const uchar *corner = pixel(x, y); + EXPECT_EQ(int(corner[3]), 0) << "corner " << x << ", " << y; + } + } +} + +TEST(PolygonGenerator, UpdateGizmoPositionsCreatesHandlesForEachPoint) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + // Only the path gizmo exists before the first update + ASSERT_EQ(node->GetGizmos().size(), 1); + + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount); + const olive::NodeGlobals globals(vparams, olive::core::AudioParams(), + olive::rational(0), + olive::LoopMode::kLoopModeOff); + + node->UpdateGizmoPositions(GenerateRow(node), globals); + + // Path gizmo + one position handle, two bezier handles and two bezier + // lines per point of the default pentagon + ASSERT_EQ(node->GetGizmos().size(), 1 + 5 + 10 + 10); + + // Without a base texture the gizmos are anchored at half the sequence + // resolution on top of each point + const double expected[5][2] = { + { 0, -135 }, { 135, -45 }, { 90, 120 }, { -90, 120 }, { -135, -45 } + }; + for (int i = 0; i < 5; i++) { + auto *position = + static_cast(node->GetGizmos().at(1 + i)); + EXPECT_EQ(position->GetPoint(), + QPointF(expected[i][0] + 160, expected[i][1] + 120)) + << "Wrong position handle for point " << i; + } + + // Bezier handles default to the point position (zero control point offsets) + auto *bezier = static_cast(node->GetGizmos().at(6)); + EXPECT_EQ(int(bezier->GetShape()), int(olive::PointGizmo::kCircle)); + EXPECT_EQ(bezier->GetPoint(), QPointF(160, -15)); + + auto *line = static_cast(node->GetGizmos().at(16)); + EXPECT_EQ(line->GetLine(), QLineF(QPointF(160, -15), QPointF(160, -15))); + + auto *path = dynamic_cast(node->GetGizmos().first()); + ASSERT_NE(path, nullptr); + // moveTo plus one cubic segment (3 elements) per edge of the pentagon + EXPECT_EQ(path->GetPath().elementCount(), 16); + + // Shrinking the point array shrinks the gizmo vectors with it + node->InputArrayResize(olive::PolygonGenerator::kPointsInput, 3); + node->UpdateGizmoPositions(GenerateRow(node), globals); + EXPECT_EQ(node->GetGizmos().size(), 1 + 3 + 6 + 6); + + // With a base texture connected the gizmos anchor at half the texture's + // virtual resolution instead of the sequence's + const olive::TexturePtr base = std::make_shared( + olive::VideoParams(64, 48, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount)); + olive::NodeValueRow row = GenerateRow(node); + row.insert(olive::GeneratorWithMerge::kBaseInput, + olive::NodeValue(olive::NodeValue::kTexture, base)); + node->UpdateGizmoPositions(row, globals); + + auto *position = + static_cast(node->GetGizmos().at(1)); + EXPECT_EQ(position->GetPoint(), QPointF(32, -111)); +} + +TEST(PolygonGenerator, DraggingPositionGizmoUpdatesPointTracks) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::NodeValueRow row = GenerateRow(node); + node->UpdateGizmoPositions(row, olive::NodeGlobals()); + ASSERT_EQ(node->GetGizmos().size(), 26); + + // The first position handle drives the X/Y tracks of the first point + auto *gizmo = static_cast(node->GetGizmos().at(1)); + gizmo->DragStart(row, 0, 0, olive::rational(0)); + gizmo->DragMove(10, -20, Qt::NoModifier); + + EXPECT_DOUBLE_EQ(node->GetSplitStandardValueOnTrack( + olive::PolygonGenerator::kPointsInput, 0, 0) + .toDouble(), + 10.0); + EXPECT_DOUBLE_EQ(node->GetSplitStandardValueOnTrack( + olive::PolygonGenerator::kPointsInput, 1, 0) + .toDouble(), + -155.0); + + // The other points are untouched + EXPECT_DOUBLE_EQ(node->GetSplitStandardValueOnTrack( + olive::PolygonGenerator::kPointsInput, 0, 1) + .toDouble(), + 135.0); + + olive::MultiUndoCommand command; + gizmo->DragEnd(&command); +} + +TEST(TextGeneratorV2, MetadataIsCorrect) +{ + olive::TextGeneratorV2 node; + EXPECT_EQ(node.id(), QStringLiteral("org.olivevideoeditor.Olive.text2")); + EXPECT_EQ(node.Name(), QStringLiteral("Text (Legacy)")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); + + // Hidden from the create menu: superseded by TextGeneratorV3 + EXPECT_TRUE(node.GetFlags() & olive::Node::kDontShowInCreateMenu); +} + +TEST(TextGeneratorV2, InputDefaults) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + EXPECT_EQ(node->GetStandardValue(olive::TextGeneratorV2::kTextInput) + .toString(), + QStringLiteral("Sample Text")); + + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV2::kHtmlInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node->GetStandardValue(olive::TextGeneratorV2::kHtmlInput) + .toBool()); + + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV2::kVAlignInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node->GetStandardValue(olive::TextGeneratorV2::kVAlignInput) + .toInt(), + 0); + + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV2::kFontInput)), + int(olive::NodeValue::kFont)); + + EXPECT_EQ( + int(node->GetInputDataType(olive::TextGeneratorV2::kFontSizeInput)), + int(olive::NodeValue::kFloat)); + EXPECT_DOUBLE_EQ(node->GetStandardValue(olive::TextGeneratorV2::kFontSizeInput) + .toDouble(), + 72.0); + + // From ShapeNodeBase: white text on a 400x300 box + const olive::core::Color color = + node->GetStandardValue(olive::ShapeNodeBase::kColorInput) + .value(); + EXPECT_FLOAT_EQ(color.red(), 1.0f); + EXPECT_FLOAT_EQ(color.green(), 1.0f); + EXPECT_FLOAT_EQ(color.blue(), 1.0f); + EXPECT_FLOAT_EQ(color.alpha(), 1.0f); + EXPECT_EQ(node->GetStandardValue(olive::ShapeNodeBase::kSizeInput) + .value(), + QVector2D(400.0f, 300.0f)); +} + +TEST(TextGeneratorV2, RetranslateSetsNamesAndComboStrings) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV2::kTextInput), + QStringLiteral("Text")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV2::kHtmlInput), + QStringLiteral("Enable HTML")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV2::kFontInput), + QStringLiteral("Font")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV2::kFontSizeInput), + QStringLiteral("Font Size")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV2::kVAlignInput), + QStringLiteral("Vertical Align")); + + // Inherited names from ShapeNodeBase and GeneratorWithMerge + 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 aligns = + node->GetComboBoxStrings(olive::TextGeneratorV2::kVAlignInput); + ASSERT_EQ(aligns.size(), 3); + EXPECT_EQ(aligns.at(0), QStringLiteral("Top")); + EXPECT_EQ(aligns.at(1), QStringLiteral("Center")); + EXPECT_EQ(aligns.at(2), QStringLiteral("Bottom")); +} + +TEST(TextGeneratorV2, ValuePushesFloatTextureWithGenerateJob) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::NodeValueTable table = GenerateTable(node, vparams); + + // Text always renders to a 32-bit float buffer regardless of sequence depth + 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::F32)); + + auto *job = dynamic_cast(texture->job()); + ASSERT_TRUE(job); + EXPECT_EQ(job->Get(olive::TextGeneratorV2::kTextInput).toString(), + QStringLiteral("Sample Text")); + EXPECT_DOUBLE_EQ(job->Get(olive::TextGeneratorV2::kFontSizeInput).toDouble(), + 72.0); + EXPECT_EQ(job->Get(olive::TextGeneratorV2::kVAlignInput).toInt(), 0); + EXPECT_EQ(job->Get(olive::ShapeNodeBase::kSizeInput).toVec2(), + QVector2D(400.0f, 300.0f)); +} + +TEST(TextGeneratorV2, EmptyTextPushesNothing) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV2::kTextInput, QString()); + + olive::NodeValueTable table = GenerateTable( + node, olive::VideoParams(320, 240, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount)); + + EXPECT_TRUE(GetOutputTexture(table) == nullptr); +} + +TEST(TextGeneratorV2, ValueIgnoresBaseInput) +{ + 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, + olive::VideoParams::kRGBAChannelCount)); + constant->SetTexture(base); + olive::Node::ConnectEdge(constant, + olive::NodeInput( + node, olive::GeneratorWithMerge::kBaseInput)); + + olive::NodeValueTable table = GenerateTable( + node, olive::VideoParams(320, 240, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + + // Unlike TextGeneratorV3, which composites its text over the base input, + // the legacy V2 node never looks at it: the output is its own generate + // job at sequence params, not a merge with the base + olive::TexturePtr texture = GetOutputTexture(table); + ASSERT_TRUE(texture); + ASSERT_TRUE(texture->IsJob()); + EXPECT_EQ(texture->params().width(), 320); + EXPECT_EQ(texture->params().height(), 240); + EXPECT_TRUE(dynamic_cast(texture->job())); +} + +TEST(TextGeneratorV2, GenerateFrameWithEmptyTextLeavesFrameTransparent) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV2::kTextInput, QString()); + + // Walk the vertical alignment switch and the HTML branch with empty text: + // no glyphs are drawn, so the transplant loop writes pure zeros + olive::NodeValueRow row = GenerateRow(node); + for (int valign = 0; valign <= 2; valign++) { + for (int html = 0; html <= 1; html++) { + row[olive::TextGeneratorV2::kVAlignInput] = + olive::NodeValue(olive::NodeValue::kCombo, valign); + row[olive::TextGeneratorV2::kHtmlInput] = + olive::NodeValue(olive::NodeValue::kBoolean, bool(html)); + + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(64, 48, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + frame->allocate(); + + node->GenerateFrame(frame, olive::GenerateJob(row)); + + const float *data = reinterpret_cast(frame->data()); + const int pixel_count = + frame->linesize_pixels() * frame->height() * + olive::VideoParams::kRGBAChannelCount; + float max_abs = 0.0f; + for (int i = 0; i < pixel_count; i++) { + max_abs = qMax(max_abs, qAbs(data[i])); + } + EXPECT_FLOAT_EQ(max_abs, 0.0f) + << "valign " << valign << ", html " << html; + } + } +} + +TEST(TextGeneratorV2, GenerateFrameRasterizesTextInColor) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue( + olive::ShapeNodeBase::kColorInput, + QVariant::fromValue(olive::core::Color(1.0f, 0.0f, 0.0f, 1.0f))); + + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(320, 240, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + frame->allocate(); + + node->GenerateFrame(frame, olive::GenerateJob(GenerateRow(node))); + + // The alpha mask of the rendered glyphs is tinted by the color input: + // red premultiplied text has red == alpha and zero green/blue everywhere + const float *data = reinterpret_cast(frame->data()); + bool any_alpha = false; + bool any_green_or_blue = false; + bool red_matches_alpha = true; + for (int y = 0; y < frame->height(); y++) { + for (int x = 0; x < frame->width(); x++) { + const float *px = data + + (y * frame->linesize_pixels() + x) * + olive::VideoParams::kRGBAChannelCount; + any_alpha |= px[3] > 0.0f; + any_green_or_blue |= (px[1] != 0.0f || px[2] != 0.0f); + red_matches_alpha &= (px[0] == px[3]); + } + } + EXPECT_TRUE(any_alpha); + EXPECT_FALSE(any_green_or_blue); + EXPECT_TRUE(red_matches_alpha); +} + +TEST(TextGeneratorV1, MetadataIsCorrect) +{ + olive::TextGeneratorV1 node; + EXPECT_EQ(node.id(), + QStringLiteral("org.olivevideoeditor.Olive.textgenerator")); + EXPECT_EQ(node.Name(), QStringLiteral("Text (Legacy)")); + EXPECT_FALSE(node.Description().isEmpty()); + EXPECT_TRUE(node.Category().contains(olive::Node::kCategoryGenerator)); + + // Hidden from the create menu: superseded by TextGeneratorV3 + EXPECT_TRUE(node.GetFlags() & olive::Node::kDontShowInCreateMenu); +} + +TEST(TextGeneratorV1, InputDefaults) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + EXPECT_EQ(node->GetStandardValue(olive::TextGeneratorV1::kTextInput) + .toString(), + QStringLiteral("Sample Text")); + + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV1::kHtmlInput)), + int(olive::NodeValue::kBoolean)); + EXPECT_FALSE(node->GetStandardValue(olive::TextGeneratorV1::kHtmlInput) + .toBool()); + + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV1::kColorInput)), + int(olive::NodeValue::kColor)); + const olive::core::Color color = + node->GetStandardValue(olive::TextGeneratorV1::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); + + // Unlike V2, V1 defaults to centered vertical alignment + EXPECT_EQ(int(node->GetInputDataType(olive::TextGeneratorV1::kVAlignInput)), + int(olive::NodeValue::kCombo)); + EXPECT_EQ(node->GetStandardValue(olive::TextGeneratorV1::kVAlignInput) + .toInt(), + 1); + + EXPECT_EQ( + int(node->GetInputDataType(olive::TextGeneratorV1::kFontSizeInput)), + int(olive::NodeValue::kFloat)); + EXPECT_DOUBLE_EQ(node->GetStandardValue(olive::TextGeneratorV1::kFontSizeInput) + .toDouble(), + 72.0); +} + +TEST(TextGeneratorV1, RetranslateSetsNamesAndComboStrings) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->Retranslate(); + + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kTextInput), + QStringLiteral("Text")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kHtmlInput), + QStringLiteral("Enable HTML")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kFontInput), + QStringLiteral("Font")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kFontSizeInput), + QStringLiteral("Font Size")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kColorInput), + QStringLiteral("Color")); + EXPECT_EQ(node->GetInputName(olive::TextGeneratorV1::kVAlignInput), + QStringLiteral("Vertical Align")); + + const QStringList aligns = + node->GetComboBoxStrings(olive::TextGeneratorV1::kVAlignInput); + ASSERT_EQ(aligns.size(), 3); + EXPECT_EQ(aligns.at(0), QStringLiteral("Top")); + EXPECT_EQ(aligns.at(1), QStringLiteral("Center")); + EXPECT_EQ(aligns.at(2), QStringLiteral("Bottom")); +} + +TEST(TextGeneratorV1, ValuePushesTextureAtSequenceParams) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + const olive::VideoParams vparams(320, 240, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + olive::NodeValueTable table = GenerateTable(node, vparams); + + // Unlike V2, V1 keeps the sequence pixel format for its output + 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); + EXPECT_EQ(job->Get(olive::TextGeneratorV1::kTextInput).toString(), + QStringLiteral("Sample Text")); + EXPECT_DOUBLE_EQ(job->Get(olive::TextGeneratorV1::kFontSizeInput).toDouble(), + 72.0); +} + +TEST(TextGeneratorV1, EmptyTextPushesNothing) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV1::kTextInput, QString()); + + olive::NodeValueTable table = GenerateTable( + node, olive::VideoParams(320, 240, olive::core::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount)); + + EXPECT_TRUE(GetOutputTexture(table) == nullptr); +} + +TEST(TextGeneratorV1, GenerateFrameWithEmptyTextLeavesFrameBlack) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + node->SetStandardValue(olive::TextGeneratorV1::kTextInput, QString()); + + // Walk the vertical alignment switch and the HTML branch with empty text: + // no glyphs are drawn, so every pixel is set to transparent black + olive::NodeValueRow row = GenerateRow(node); + for (int valign = 0; valign <= 2; valign++) { + for (int html = 0; html <= 1; html++) { + row[olive::TextGeneratorV1::kVAlignInput] = + olive::NodeValue(olive::NodeValue::kCombo, valign); + row[olive::TextGeneratorV1::kHtmlInput] = + olive::NodeValue(olive::NodeValue::kBoolean, bool(html)); + + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(64, 48, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + frame->allocate(); + + node->GenerateFrame(frame, olive::GenerateJob(row)); + + const float *data = reinterpret_cast(frame->data()); + const int pixel_count = + frame->linesize_pixels() * frame->height() * + olive::VideoParams::kRGBAChannelCount; + float max_abs = 0.0f; + for (int i = 0; i < pixel_count; i++) { + max_abs = qMax(max_abs, qAbs(data[i])); + } + EXPECT_FLOAT_EQ(max_abs, 0.0f) + << "valign " << valign << ", html " << html; + } + } +} + +TEST(TextGeneratorV1, GenerateFrameRasterizesTextPixels) +{ + olive::ColorManager::SetUpDefaultConfig(); + olive::Project project; + project.Initialize(); + + auto *node = AddNode(&project); + + olive::FramePtr frame = olive::Frame::Create(); + frame->set_video_params( + olive::VideoParams(320, 240, olive::core::PixelFormat::F32, + olive::VideoParams::kRGBAChannelCount)); + frame->allocate(); + + node->GenerateFrame(frame, olive::GenerateJob(GenerateRow(node))); + + // The default white text is written premultiplied: any covered pixel has + // all channels equal to its alpha + const float *data = reinterpret_cast(frame->data()); + bool any_alpha = false; + bool channels_match_alpha = true; + for (int y = 0; y < frame->height(); y++) { + for (int x = 0; x < frame->width(); x++) { + const float *px = data + + (y * frame->linesize_pixels() + x) * + olive::VideoParams::kRGBAChannelCount; + any_alpha |= px[3] > 0.0f; + channels_match_alpha &= + (px[0] == px[3] && px[1] == px[3] && px[2] == px[3]); + } + } + EXPECT_TRUE(any_alpha); + EXPECT_TRUE(channels_match_alpha); +} diff --git a/tests/gtest/node_save_load_test.cpp b/tests/gtest/node_save_load_test.cpp new file mode 100644 index 000000000..262520f05 --- /dev/null +++ b/tests/gtest/node_save_load_test.cpp @@ -0,0 +1,619 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include "common/xmlutils.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/factory.h" +#include "node/generator/solid/solid.h" +#include "node/generator/text/textv3.h" +#include "node/keyframe.h" +#include "node/math/math/math.h" +#include "node/node.h" +#include "node/project.h" +#include "node/project/folder/folder.h" +#include "node/serializeddata.h" +#include "node/splitvalue.h" +#include "render/diskmanager.h" + +namespace +{ + +// Serializes a single node into a standalone XML document, mirroring how +// Project::Save wraps Node::Save in a "node" element +QString SaveNodeXml(const olive::Node *node) +{ + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("node")); + node->Save(&writer); + writer.writeEndElement(); // node + writer.writeEndDocument(); + return xml; +} + +// Loads a document produced by SaveNodeXml into an existing node +bool LoadNodeXml(olive::Node *node, const QString &xml, + olive::SerializedData *data) +{ + QXmlStreamReader reader(xml); + if (!reader.readNextStartElement()) { + return false; + } + if (reader.name() != QStringLiteral("node")) { + return false; + } + return node->Load(&reader, data); +} + +olive::Node *FindNodeById(olive::Project *project, const QString &id) +{ + for (olive::Node *n : project->nodes()) { + if (n->id() == id) { + return n; + } + } + return nullptr; +} + +// Node that round-trips a custom payload through SaveCustom/LoadCustom and +// records LoadFinishedEvent +class CustomDataNode : public olive::Node { +public: + CustomDataNode() + { + AddInput(QStringLiteral("Value"), olive::NodeValue::kFloat); + } + + NODE_DEFAULT_FUNCTIONS(CustomDataNode) + + virtual QString Name() const override + { + return QStringLiteral("CustomDataNode"); + } + + virtual QString id() const override + { + return QStringLiteral("org.oak.test.customdatanode"); + } + + virtual QVector Category() const override + { + return { kCategoryUnknown }; + } + + virtual QString Description() const override + { + return QStringLiteral("Node with custom serialized data"); + } + + void Value(const olive::NodeValueRow &, const olive::NodeGlobals &, + olive::NodeValueTable *) const override + { + } + + virtual void SaveCustom(QXmlStreamWriter *writer) const override + { + writer->writeTextElement(QStringLiteral("greeting"), greeting_); + } + + virtual bool LoadCustom(QXmlStreamReader *reader, + olive::SerializedData *data) override + { + Q_UNUSED(data) + + while (olive::XMLReadNextStartElement(reader)) { + if (reader->name() == QStringLiteral("greeting")) { + greeting_ = reader->readElementText(); + } else if (reader->name() == QStringLiteral("explode")) { + reader->skipCurrentElement(); + return false; + } else { + reader->skipCurrentElement(); + } + } + + return true; + } + + virtual void LoadFinishedEvent() override + { + load_finished_called_ = true; + } + + QString greeting_; + bool load_finished_called_ = false; +}; + +} // namespace + +class NodeSaveLoadTest : public ::testing::Test { +protected: + void SetUp() override + { + olive::ColorManager::SetUpDefaultConfig(); + + // Cache UUID changes resolve a cache path through the DiskManager + // singleton, which itself touches Core (same pattern as + // project_factory_test) + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + if (!olive::DiskManager::instance()) { + olive::DiskManager::CreateInstance(); + } + + project_ = std::make_unique(); + project_->Initialize(); + } + + template T *AddNode() + { + T *node = new T(); + node->setParent(project_.get()); + return node; + } + + std::unique_ptr project_; +}; + +TEST_F(NodeSaveLoadTest, StandardValuesLabelAndColorRoundTrip) +{ + auto *src = AddNode(); + src->SetLabel(QStringLiteral("Labeled")); + src->SetOverrideColor(3); + src->SetStandardValue(olive::MathNode::kParamAIn, 3.5); + src->SetStandardValue(olive::MathNode::kParamBIn, -2.25); + src->SetOperation(olive::MathNode::kOpMultiply); + + const QString xml = SaveNodeXml(src); + EXPECT_TRUE(xml.contains(QStringLiteral("version=\"1\""))); + EXPECT_TRUE(xml.contains( + QStringLiteral("id=\"org.olivevideoeditor.Olive.math\""))); + + olive::MathNode loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + EXPECT_EQ(loaded.GetLabel(), QStringLiteral("Labeled")); + EXPECT_EQ(loaded.GetOverrideColor(), 3); + EXPECT_DOUBLE_EQ( + loaded.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 3.5); + EXPECT_DOUBLE_EQ( + loaded.GetStandardValue(olive::MathNode::kParamBIn).toDouble(), -2.25); + EXPECT_EQ(int(loaded.GetOperation()), int(olive::MathNode::kOpMultiply)); + + // The "ptr" attribute maps the serialized address to the loaded instance + EXPECT_EQ(data.node_ptrs.value(reinterpret_cast(src)), &loaded); + + // A non-keyframable input never reports keyframing after load + EXPECT_FALSE(loaded.IsInputKeyframing(olive::MathNode::kMethodIn)); +} + +TEST_F(NodeSaveLoadTest, ArrayElementsAndPerElementKeyframingRoundTrip) +{ + auto *src = AddNode(); + src->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + src->SetStandardValue( + olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 0), + QStringLiteral("first")); + src->SetStandardValue( + olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 1), + QStringLiteral("second")); + + // Only element 1 is keyframed + src->SetInputIsKeyframing(olive::TextGeneratorV3::kArgsInput, true, 1); + auto *key = new olive::NodeKeyframe( + olive::rational(2), QStringLiteral("keyed"), olive::NodeKeyframe::kLinear, + 0, 1, olive::TextGeneratorV3::kArgsInput); + key->setParent(src); + + const QString xml = SaveNodeXml(src); + + olive::TextGeneratorV3 loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + // The subelement count attribute resized the array on load + ASSERT_EQ(loaded.InputArraySize(olive::TextGeneratorV3::kArgsInput), 2); + EXPECT_EQ(loaded.GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 0) + .at(0) + .toString(), + QStringLiteral("first")); + EXPECT_EQ(loaded.GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 1) + .at(0) + .toString(), + QStringLiteral("second")); + + EXPECT_FALSE( + loaded.IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 0)); + EXPECT_TRUE(loaded.IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 1)); + + const QVector &tracks = + loaded.GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1); + ASSERT_EQ(tracks.at(0).size(), 1); + EXPECT_EQ(tracks.at(0).first()->time(), olive::rational(2)); + EXPECT_EQ(tracks.at(0).first()->value().toString(), + QStringLiteral("keyed")); + EXPECT_EQ(tracks.at(0).first()->element(), 1); +} + +TEST_F(NodeSaveLoadTest, KeyframesAllTypesAndColorPropertiesRoundTrip) +{ + auto *src = AddNode(); + + olive::SplitValue color; + color.append(0.25); + color.append(0.5); + color.append(0.75); + color.append(1.0); + src->SetSplitStandardValue(olive::SolidGenerator::kColorInput, color, -1); + + src->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true); + + auto *linear = new olive::NodeKeyframe( + olive::rational(0), 0.0, olive::NodeKeyframe::kLinear, 0, -1, + olive::SolidGenerator::kColorInput); + linear->setParent(src); + auto *bezier = new olive::NodeKeyframe( + olive::rational(5), 1.0, olive::NodeKeyframe::kBezier, 0, -1, + olive::SolidGenerator::kColorInput); + bezier->setParent(src); + bezier->set_bezier_control_in(QPointF(0.25, -1.5)); + bezier->set_bezier_control_out(QPointF(2.5, 0.75)); + auto *hold = new olive::NodeKeyframe( + olive::rational(3), 0.5, olive::NodeKeyframe::kHold, 2, -1, + olive::SolidGenerator::kColorInput); + hold->setParent(src); + + // Color inputs additionally serialize their color management properties + src->SetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_input"), QStringLiteral("ACEScg")); + src->SetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_display"), QStringLiteral("sRGB")); + src->SetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_view"), QStringLiteral("Filmic")); + src->SetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_look"), QStringLiteral("None")); + + const QString xml = SaveNodeXml(src); + + olive::SolidGenerator loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + EXPECT_TRUE(loaded.IsInputKeyframing(olive::SolidGenerator::kColorInput)); + + const QVector &tracks = + loaded.GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1); + ASSERT_EQ(tracks.size(), 4); + + // Track 0 holds the linear and bezier keys, sorted by time + ASSERT_EQ(tracks.at(0).size(), 2); + EXPECT_EQ(tracks.at(0).at(0)->time(), olive::rational(0)); + EXPECT_EQ(tracks.at(0).at(0)->type(), olive::NodeKeyframe::kLinear); + EXPECT_DOUBLE_EQ(tracks.at(0).at(0)->value().toDouble(), 0.0); + EXPECT_EQ(tracks.at(0).at(1)->time(), olive::rational(5)); + EXPECT_EQ(tracks.at(0).at(1)->type(), olive::NodeKeyframe::kBezier); + EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->value().toDouble(), 1.0); + EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_in().x(), 0.25); + EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_in().y(), -1.5); + EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_out().x(), 2.5); + EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_out().y(), 0.75); + + // Track 1 was left empty, track 2 holds the single hold key + EXPECT_TRUE(tracks.at(1).isEmpty()); + ASSERT_EQ(tracks.at(2).size(), 1); + EXPECT_EQ(tracks.at(2).first()->time(), olive::rational(3)); + EXPECT_EQ(tracks.at(2).first()->type(), olive::NodeKeyframe::kHold); + EXPECT_DOUBLE_EQ(tracks.at(2).first()->value().toDouble(), 0.5); + EXPECT_TRUE(tracks.at(3).isEmpty()); + + // The per-track standard values survive as well + const olive::SplitValue loaded_color = + loaded.GetSplitStandardValue(olive::SolidGenerator::kColorInput, -1); + ASSERT_EQ(loaded_color.size(), 4); + EXPECT_DOUBLE_EQ(loaded_color.at(0).toDouble(), 0.25); + EXPECT_DOUBLE_EQ(loaded_color.at(1).toDouble(), 0.5); + EXPECT_DOUBLE_EQ(loaded_color.at(2).toDouble(), 0.75); + EXPECT_DOUBLE_EQ(loaded_color.at(3).toDouble(), 1.0); + + EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_input")) + .toString(), + QStringLiteral("ACEScg")); + EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_display")) + .toString(), + QStringLiteral("sRGB")); + EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_view")) + .toString(), + QStringLiteral("Filmic")); + EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput, + QStringLiteral("col_look")) + .toString(), + QStringLiteral("None")); +} + +TEST_F(NodeSaveLoadTest, ValueHintsRoundTrip) +{ + auto *src = AddNode(); + src->SetValueHintForInput( + olive::MathNode::kParamAIn, + olive::Node::ValueHint( + { olive::NodeValue::kVec2, olive::NodeValue::kTexture }, 3, + QStringLiteral("tag"))); + src->SetValueHintForInput(olive::MathNode::kParamBIn, + olive::Node::ValueHint(QStringLiteral("elem")), 2); + + const QString xml = SaveNodeXml(src); + + olive::MathNode loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + const olive::Node::ValueHint hint = + loaded.GetValueHintForInput(olive::MathNode::kParamAIn); + ASSERT_EQ(hint.types().size(), 2); + EXPECT_EQ(hint.types().at(0), olive::NodeValue::kVec2); + EXPECT_EQ(hint.types().at(1), olive::NodeValue::kTexture); + EXPECT_EQ(hint.index(), 3); + EXPECT_EQ(hint.tag(), QStringLiteral("tag")); + + // Hints are tracked per element + EXPECT_EQ(loaded.GetValueHintForInput(olive::MathNode::kParamBIn, 2).tag(), + QStringLiteral("elem")); + EXPECT_EQ(loaded.GetValueHintForInput(olive::MathNode::kParamBIn, 1).tag(), + QString()); + EXPECT_EQ(loaded.GetValueHints().size(), 2); +} + +TEST_F(NodeSaveLoadTest, CacheUuidsRoundTrip) +{ + olive::MathNode src; + + const QUuid audio_uuid( + QStringLiteral("{11111111-1111-1111-1111-111111111111}")); + const QUuid video_uuid( + QStringLiteral("{22222222-2222-2222-2222-222222222222}")); + const QUuid thumb_uuid( + QStringLiteral("{33333333-3333-3333-3333-333333333333}")); + const QUuid waveform_uuid( + QStringLiteral("{44444444-4444-4444-4444-444444444444}")); + + src.audio_playback_cache()->SetUuid(audio_uuid); + src.video_frame_cache()->SetUuid(video_uuid); + src.thumbnail_cache()->SetUuid(thumb_uuid); + src.waveform_cache()->SetUuid(waveform_uuid); + + const QString xml = SaveNodeXml(&src); + + olive::MathNode loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + EXPECT_EQ(loaded.audio_playback_cache()->GetUuid(), audio_uuid); + EXPECT_EQ(loaded.video_frame_cache()->GetUuid(), video_uuid); + EXPECT_EQ(loaded.thumbnail_cache()->GetUuid(), thumb_uuid); + EXPECT_EQ(loaded.waveform_cache()->GetUuid(), waveform_uuid); +} + +TEST_F(NodeSaveLoadTest, CustomDataAndLoadFinishedEventRoundTrip) +{ + CustomDataNode src; + src.greeting_ = QStringLiteral("hello custom"); + + const QString xml = SaveNodeXml(&src); + EXPECT_TRUE(xml.contains(QStringLiteral("hello custom"))); + + CustomDataNode loaded; + olive::SerializedData data; + ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data)); + + EXPECT_EQ(loaded.greeting_, QStringLiteral("hello custom")); + EXPECT_TRUE(loaded.load_finished_called_); + + // A LoadCustom failure propagates out of Node::Load + const QString fail_xml = QStringLiteral( + ""); + CustomDataNode failing; + olive::SerializedData fail_data; + QXmlStreamReader reader(fail_xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("node")); + EXPECT_FALSE(failing.Load(&reader, &fail_data)); +} + +TEST_F(NodeSaveLoadTest, UnknownElementsAndVersionAreSkipped) +{ + olive::MathNode node; + + // Unknown elements are skipped at every level of the node format, and an + // unrecognized version attribute does not fail the load + const QString xml = QStringLiteral( + "" + "" + "" + "" + "" + "" + "" + "" + "12345" + "" + "" + "" + "" + "" + "{00000000-0000-0000-0000-000000000000}" + "" + "" + ""); + + olive::SerializedData data; + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("node")); + EXPECT_TRUE(node.Load(&reader, &data)); + + EXPECT_EQ(node.GetLabel(), QStringLiteral("kept")); + + // The one well-formed connection was still recorded + ASSERT_EQ(data.desired_connections.size(), 1); + EXPECT_EQ(data.desired_connections.first().input.input(), + olive::MathNode::kParamAIn); + EXPECT_EQ(data.desired_connections.first().input.element(), -1); + EXPECT_EQ(data.desired_connections.first().output_node, quintptr(12345)); + + // The malformed input left the default value untouched + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0); +} + +TEST_F(NodeSaveLoadTest, LoadInputWithMissingOrUnknownIdIsSkipped) +{ + olive::MathNode node; + + // An input with no id and an input whose id does not exist on the node + // both make LoadInput fail internally, but Node::Load ignores that return + // value and carries on + const QString xml = QStringLiteral( + "" + "9" + "" + "9" + "" + ""); + + olive::SerializedData data; + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("node")); + EXPECT_TRUE(node.Load(&reader, &data)); + + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0); + EXPECT_DOUBLE_EQ( + node.GetStandardValue(olive::MathNode::kParamBIn).toDouble(), 0.0); +} + +TEST_F(NodeSaveLoadTest, ConnectionsLinksAndPositionsResolveAfterProjectLoad) +{ + olive::NodeFactory::Initialize(); + + auto *src = AddNode(); + auto *dst = AddNode(); + auto *text = AddNode(); + text->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2); + + olive::Node::ConnectEdge( + src, olive::NodeInput(dst, olive::MathNode::kParamAIn)); + olive::Node::ConnectEdge( + dst, olive::NodeInput(text, olive::TextGeneratorV3::kArgsInput, 1)); + olive::Node::Link(src, dst); + + olive::Folder *root = project_->root(); + root->SetNodePositionInContext( + src, olive::Node::Position(QPointF(10.0, 20.0), true)); + root->SetNodePositionInContext( + dst, olive::Node::Position(QPointF(-3.5, 7.25), false)); + + QString xml; + QXmlStreamWriter writer(&xml); + writer.writeStartDocument(); + writer.writeStartElement(QStringLiteral("project")); + project_->Save(&writer); + writer.writeEndElement(); // project + writer.writeEndDocument(); + + // The project being loaded into must not be Initialize()d: Load() + // re-resolves the root folder from the saved settings + olive::Project loaded; + olive::SerializedData data; + { + QXmlStreamReader reader(xml); + ASSERT_TRUE(reader.readNextStartElement()); + ASSERT_EQ(reader.name(), QStringLiteral("project")); + data = loaded.Load(&reader); + } + + // Root folder plus the three nodes created above + ASSERT_EQ(loaded.nodes().size(), 4); + + olive::Node *loaded_src = FindNodeById(&loaded, src->id()); + olive::Node *loaded_dst = FindNodeById(&loaded, dst->id()); + olive::Node *loaded_text = FindNodeById(&loaded, text->id()); + ASSERT_NE(loaded_src, nullptr); + ASSERT_NE(loaded_dst, nullptr); + ASSERT_NE(loaded_text, nullptr); + + // Both edges were recorded against the serialized addresses, including + // the array element index on the text input + ASSERT_EQ(data.desired_connections.size(), 2); + bool found_math_edge = false; + bool found_text_edge = false; + for (const auto &sc : data.desired_connections) { + if (sc.input.node() == loaded_dst) { + EXPECT_EQ(sc.input.input(), olive::MathNode::kParamAIn); + EXPECT_EQ(sc.input.element(), -1); + EXPECT_EQ(sc.output_node, reinterpret_cast(src)); + found_math_edge = true; + } else if (sc.input.node() == loaded_text) { + EXPECT_EQ(sc.input.input(), olive::TextGeneratorV3::kArgsInput); + EXPECT_EQ(sc.input.element(), 1); + EXPECT_EQ(sc.output_node, reinterpret_cast(dst)); + found_text_edge = true; + } + } + EXPECT_TRUE(found_math_edge); + EXPECT_TRUE(found_text_edge); + + // Both nodes wrote their side of the link + EXPECT_EQ(data.block_links.size(), 2); + + // The root folder recorded positions for the two placed nodes + olive::Folder *loaded_root = loaded.root(); + ASSERT_NE(loaded_root, nullptr); + EXPECT_EQ(data.positions.value(loaded_root).size(), 2); + + // Resolve the deferred state the same way + // ProjectSerializer230220::PostConnect does + for (const auto &sc : data.desired_connections) { + if (olive::Node *out = data.node_ptrs.value(sc.output_node)) { + olive::Node::ConnectEdge(out, sc.input); + } + } + for (const auto &link : data.block_links) { + olive::Node::Link(link.block, data.node_ptrs.value(link.link)); + } + for (olive::Node *n : loaded.nodes()) { + n->PostLoadEvent(&data); + } + + EXPECT_EQ(loaded_dst->GetConnectedOutput(olive::MathNode::kParamAIn), + loaded_src); + EXPECT_EQ(loaded_text->GetConnectedOutput( + olive::TextGeneratorV3::kArgsInput, 1), + loaded_dst); + EXPECT_TRUE(olive::Node::AreLinked(loaded_src, loaded_dst)); + EXPECT_TRUE(olive::Node::AreLinked(loaded_dst, loaded_src)); + + EXPECT_EQ(loaded_root->GetNodePositionInContext(loaded_src), + QPointF(10.0, 20.0)); + EXPECT_TRUE(loaded_root->IsNodeExpandedInContext(loaded_src)); + EXPECT_EQ(loaded_root->GetNodePositionInContext(loaded_dst), + QPointF(-3.5, 7.25)); + EXPECT_FALSE(loaded_root->IsNodeExpandedInContext(loaded_dst)); + + olive::NodeFactory::Destroy(); +} diff --git a/tests/gtest/render_tail_test.cpp b/tests/gtest/render_tail_test.cpp new file mode 100644 index 000000000..454e74961 --- /dev/null +++ b/tests/gtest/render_tail_test.cpp @@ -0,0 +1,872 @@ +#include + +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "codec/conformmanager.h" +#include "config/config.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/generator/solid/solid.h" +#include "node/keying/chromakey/chromakey.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "olive/core/render/audioparams.h" +#include "olive/core/render/samplebuffer.h" +#include "render/audioplaybackcache.h" +#include "render/backend/dynamicrenderer.h" +#include "render/colorprocessor.h" +#include "render/diskmanager.h" +#include "render/job/colortransformjob.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 that records the shader code it is asked to compile +// so Renderer::GetColorContext() shader generation can be verified without a +// GL/Vulkan backend. +class ShaderCaptureRenderer : public olive::Renderer { +public: + ShaderCaptureRenderer() + : create_shader_count(0) + , create_texture_count(0) + , blit_count(0) + { + } + + 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++; + last_frag_code = code.frag_code(); + last_vert_code = code.vert_code(); + return QVariant(create_shader_count); + } + + void DestroyNativeShader(QVariant shader) override + { + } + + void UploadToTexture(const QVariant &handle, const olive::VideoParams ¶ms, + const void *data, int linesize) override + { + } + + void DownloadFromTexture(const QVariant &handle, + const olive::VideoParams ¶ms, void *data, + int linesize) override + { + } + + void Flush() override + { + } + + olive::Color GetPixelFromTexture(olive::Texture *texture, + const QPointF &pt) override + { + return olive::Color(); + } + + int create_shader_count; + int create_texture_count; + int blit_count; + QString last_frag_code; + QString last_vert_code; + +protected: + void Blit(QVariant shader, olive::AcceleratedJob &job, + olive::Texture *destination, olive::VideoParams destination_params, + bool clear_destination) override + { + blit_count++; + } + + QVariant CreateNativeTexture(int width, int height, int depth, + olive::PixelFormat format, int channel_count, + const void *data, int linesize) override + { + create_texture_count++; + return QVariant(create_texture_count); + } + + void DestroyNativeTexture(QVariant texture) override + { + } + + void DestroyInternal() override + { + } +}; + +olive::ColorProcessorPtr MakeIdentityProcessor() +{ + olive::ColorManager::SetUpDefaultConfig(); + + OCIO::MatrixTransformRcPtr transform = OCIO::MatrixTransform::Create(); + transform->setDirection(OCIO::TRANSFORM_DIR_FORWARD); + + return olive::ColorProcessor::Create( + olive::ColorManager::GetDefaultConfig()->getProcessor(transform)); +} + +bool WriteFile(const QString &path, qint64 size) +{ + QFile file(path); + if (!file.open(QFile::WriteOnly)) { + return false; + } + file.write(QByteArray(static_cast(size), 'x')); + file.close(); + return true; +} + +bool ReadBytesAt(const QString &path, qint64 offset, qint64 len, QByteArray *out) +{ + QFile f(path); + if (!f.open(QFile::ReadOnly)) { + return false; + } + if (!f.seek(offset)) { + return false; + } + *out = f.read(len); + return out->size() == len; +} + +float BytesToFloat(const QByteArray &bytes) +{ + float v; + memcpy(&v, bytes.constData(), sizeof(v)); + return v; +} + +// AudioPlaybackCache always stores audio in fixed-size segments of 10 MB per +// channel (AudioPlaybackCache::kDefaultSegmentSizePerChannel). +const qint64 kSegmentSize = 10 * 1024 * 1024; + +} // namespace + +// A DynamicRenderer constructed with an empty backend name must report no +// backend type; the name is stored verbatim (only lowercased). +TEST(DynamicRenderer, EmptyBackendNameHasNoBackendType) +{ + olive::DynamicRenderer renderer{ QString() }; + EXPECT_TRUE(renderer.backend_name().isEmpty()); + EXPECT_FALSE(renderer.IsOpenGL()); + EXPECT_FALSE(renderer.IsVulkan()); + + // Without a loaded backend, context and info accessors stay at defaults + EXPECT_EQ(renderer.OpenGLContext(), nullptr); + + OakRenderBackendInfo info = {}; + EXPECT_FALSE(renderer.GetBackendInfo(&info)); +} + +// BackendFromString lowercases its input before comparing, so mixed-case +// spellings of every backend must resolve correctly. +TEST(RenderManagerBackendStrings, FromStringIsCaseInsensitive) +{ + EXPECT_EQ(olive::RenderManager::BackendFromString(QStringLiteral("VULKAN")), + olive::RenderManager::kVulkan); + EXPECT_EQ( + olive::RenderManager::BackendFromString(QStringLiteral("MultiProcess")), + olive::RenderManager::kMultiProcess); + EXPECT_EQ(olive::RenderManager::BackendFromString(QStringLiteral("DUMMY")), + olive::RenderManager::kDummy); + + // Unknown and empty strings fall through to OpenGL + EXPECT_EQ(olive::RenderManager::BackendFromString(QString()), + olive::RenderManager::kOpenGL); +} + +// BackendToString has a default return after the switch for out-of-range enum +// values, which must be the OpenGL string. +TEST(RenderManagerBackendStrings, ToStringFallsBackToOpenGLForUnknownEnum) +{ + EXPECT_EQ(olive::RenderManager::BackendToString( + static_cast(42)), + QStringLiteral("opengl")); +} + +// A ColorTransformJob with a custom OCIO function name must have that name +// embedded in the shader generated by GetColorContext (it is passed to +// GpuShaderDesc::setFunctionName). +TEST(RendererColorContext, CustomFunctionNameIsCompiledIntoShader) +{ + ShaderCaptureRenderer renderer; + + olive::ColorProcessorPtr processor = MakeIdentityProcessor(); + ASSERT_TRUE(processor); + + olive::ColorTransformJob job; + job.SetColorProcessor(processor); + job.SetFunctionName(QStringLiteral("MyCustomOcioFunc")); + + const olive::VideoParams params(32, 32, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + renderer.BlitColorManaged(job, params); + + ASSERT_EQ(renderer.create_shader_count, 1); + EXPECT_TRUE( + renderer.last_frag_code.contains(QStringLiteral("MyCustomOcioFunc"))); + EXPECT_EQ(renderer.blit_count, 1); + + renderer.Destroy(); +} + +// When the job names a custom shader source node, GetColorContext must ask the +// node for its shader code (with the OCIO stub) instead of using the built-in +// colormanage stub. ChromaKeyNode wraps the stub in its chroma-key shader. +TEST(RendererColorContext, CustomShaderSourceSuppliesFragmentCode) +{ + ShaderCaptureRenderer renderer; + + olive::ColorProcessorPtr processor = MakeIdentityProcessor(); + ASSERT_TRUE(processor); + + olive::ChromaKeyNode key_node; + + olive::ColorTransformJob job; + job.SetColorProcessor(processor); + job.SetNeedsCustomShader(&key_node); + + const olive::VideoParams params(32, 32, olive::PixelFormat::U8, + olive::VideoParams::kRGBAChannelCount); + renderer.BlitColorManaged(job, params); + + ASSERT_EQ(renderer.create_shader_count, 1); + // A uniform name unique to chromakey.frag proves the node's code was used + EXPECT_TRUE(renderer.last_frag_code.contains(QStringLiteral("color_key"))); + EXPECT_EQ(renderer.blit_count, 1); + + renderer.Destroy(); +} + +class RenderTailAutoCacherTest : 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(); + } + + olive::ViewerOutput *CreateViewerWithParams() + { + 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)); + return viewer; + } + + std::unique_ptr project_; +}; + +// While renders are paused, a forced cache range must sit in the pending queue; +// unpausing dispatches it and emits StopCacheProxyTasks once the (single frame) +// range iterator is exhausted. +TEST_F(RenderTailAutoCacherTest, PausedRendersDelayForcedCacheRange) +{ + olive::ViewerOutput *viewer = CreateViewerWithParams(); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks); + + cacher.SetRendersPaused(true); + cacher.ForceCacheRange( + viewer, olive::TimeRange(olive::rational(0), olive::rational(1, 25))); + EXPECT_EQ(stop_spy.count(), 0); + + cacher.SetRendersPaused(false); + EXPECT_GE(stop_spy.count(), 1); + + // Deliver the queued RenderTicketWatcher::Finished emissions so the + // completed watchers are reaped before teardown. + QCoreApplication::processEvents(); + + cacher.SetProject(nullptr); +} + +// The thumbnail pause gates only the video-job half of TryRender, so a forced +// cache range queued while thumbnails are paused must wait for the unpause. +TEST_F(RenderTailAutoCacherTest, PausedThumbnailsDelayForcedCacheRange) +{ + olive::ViewerOutput *viewer = CreateViewerWithParams(); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks); + + cacher.SetThumbnailsPaused(true); + cacher.ForceCacheRange( + viewer, olive::TimeRange(olive::rational(0), olive::rational(1, 25))); + EXPECT_EQ(stop_spy.count(), 0); + + cacher.SetThumbnailsPaused(false); + EXPECT_GE(stop_spy.count(), 1); + + QCoreApplication::processEvents(); + + cacher.SetProject(nullptr); +} + +// With a project set, GetSingleFrame resolves the node through the ProjectCopier +// and dispatches a real render ticket. With the dummy backend the underlying +// ticket finishes without a result, and the passthrough ticket must be finished +// once the watcher signals completion. +TEST_F(RenderTailAutoCacherTest, GetSingleFrameDispatchesThroughProjectCopy) +{ + olive::ViewerOutput *viewer = CreateViewerWithParams(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(project_.get()); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + olive::RenderTicketPtr ticket = + cacher.GetSingleFrame(solid, viewer, olive::rational(0)); + ASSERT_NE(ticket, nullptr); + EXPECT_TRUE(ticket->IsRunning()); + + // The dummy backend has no render threads, so the dispatched ticket can + // only be finished through the clear path (covered in detail by the + // ClearSingleFrameRenders tests below). + cacher.ClearSingleFrameRenders(); + + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_FALSE(ticket->HasResult()); + + cacher.SetProject(nullptr); +} + +// ClearSingleFrameRenders must cancel every dispatched (but no longer running) +// single-frame passthrough: the ticket is finished without a result and the +// watcher is reaped synchronously through VideoRendered. +TEST_F(RenderTailAutoCacherTest, ClearSingleFrameRendersFinishesDispatchedTicket) +{ + olive::ViewerOutput *viewer = CreateViewerWithParams(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(project_.get()); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + olive::RenderTicketPtr ticket = + cacher.GetSingleFrame(solid, viewer, olive::rational(0)); + ASSERT_NE(ticket, nullptr); + ASSERT_TRUE(ticket->IsRunning()); + + cacher.ClearSingleFrameRenders(); + + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_FALSE(ticket->HasResult()); + + // Flush the stale queued watcher notification (its receiver is gone now). + QCoreApplication::processEvents(); + + cacher.SetProject(nullptr); +} + +// ClearSingleFrameRendersThatArentRunning follows the same path for the dummy +// backend, whose tickets are never running by the time they can be cleared. +TEST_F(RenderTailAutoCacherTest, + ClearSingleFrameRendersThatArentRunningFinishesDispatchedTicket) +{ + olive::ViewerOutput *viewer = CreateViewerWithParams(); + + auto *solid = new olive::SolidGenerator(); + solid->setParent(project_.get()); + + olive::PreviewAutoCacher cacher; + cacher.SetProject(project_.get()); + + olive::RenderTicketPtr ticket = + cacher.GetSingleFrame(solid, viewer, olive::rational(0)); + ASSERT_NE(ticket, nullptr); + ASSERT_TRUE(ticket->IsRunning()); + + cacher.ClearSingleFrameRendersThatArentRunning(); + + EXPECT_EQ(ticket->GetFinishCount(), 1); + EXPECT_FALSE(ticket->IsRunning()); + EXPECT_FALSE(ticket->HasResult()); + + QCoreApplication::processEvents(); + + cacher.SetProject(nullptr); +} + +class RenderTailDiskCacheTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!temp_dir_.isValid()) { + GTEST_FAIL() << "Failed to create temporary directory"; + } + + if (!olive::Core::instance()) { + // Leaked intentionally: matches render_diskcache_test, Core is + // process-wide and eviction paths call Core::WarnCacheFull(). + new olive::Core(olive::Core::CoreParams()); + } + + olive::DiskManager::CreateInstance(); + } + + void TearDown() override + { + olive::DiskManager::DestroyInstance(); + } + + QString MakeSubDir(const QString &name) const + { + QDir root(temp_dir_.path()); + if (!root.mkpath(name)) { + return QString(); + } + return root.filePath(name); + } + + QTemporaryDir temp_dir_; +}; + +// Moving a folder to a new path must broadcast DeletedFrame for every tracked +// file (without deleting anything on disk) and reset the folder state to +// defaults before loading the new path's index. +TEST_F(RenderTailDiskCacheTest, SetPathEmitsDeletedFramesAndResetsState) +{ + const QString sub1 = MakeSubDir(QStringLiteral("move_from")); + const QString sub2 = MakeSubDir(QStringLiteral("move_to")); + ASSERT_FALSE(sub1.isEmpty()); + ASSERT_FALSE(sub2.isEmpty()); + + olive::DiskCacheFolder folder(sub1); + folder.SetLimit(12345); + + const QString fn = QDir(sub1).filePath(QStringLiteral("frame")); + ASSERT_TRUE(WriteFile(fn, 64)); + folder.CreatedFile(fn); + + QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame); + + folder.SetPath(sub2); + + EXPECT_EQ(folder.GetPath(), sub2); + EXPECT_EQ(folder.GetLimit(), 21474836480LL); // back to the 20 GB default + EXPECT_FALSE(folder.GetClearOnClose()); + + ASSERT_EQ(spy.count(), 1); + const QList args = spy.takeFirst(); + EXPECT_EQ(args.at(0).toString(), sub1); + EXPECT_EQ(args.at(1).toString(), fn); + + // The file itself is untouched, but it is no longer tracked + EXPECT_TRUE(QFileInfo::exists(fn)); + EXPECT_FALSE(folder.DeleteSpecificFile(fn)); +} + +// When the persisted index references files that have since been deleted +// externally, those entries must be skipped on load while surviving files are +// still picked up. +TEST_F(RenderTailDiskCacheTest, PersistedIndexSkipsFilesThatNoLongerExist) +{ + const QString sub = MakeSubDir(QStringLiteral("index_skip")); + ASSERT_FALSE(sub.isEmpty()); + + const QString keep = QDir(sub).filePath(QStringLiteral("keep")); + const QString gone = QDir(sub).filePath(QStringLiteral("gone")); + ASSERT_TRUE(WriteFile(keep, 32)); + ASSERT_TRUE(WriteFile(gone, 32)); + + { + olive::DiskCacheFolder folder(sub); + folder.CreatedFile(keep); + folder.CreatedFile(gone); + // Destruction writes the index file into the cache folder + } + + ASSERT_TRUE(QFile::remove(gone)); + + { + olive::DiskCacheFolder reopened(sub); + + // The missing file was not re-registered, the surviving one was + EXPECT_TRUE(reopened.DeleteSpecificFile(keep)); + EXPECT_FALSE(reopened.DeleteSpecificFile(gone)); + EXPECT_FALSE(QFileInfo::exists(keep)); + } +} + +// The clear-on-close flag is serialized into the index along with the limit, +// so a folder reopened after closing with the flag set must restore it. +TEST_F(RenderTailDiskCacheTest, ClearOnCloseFlagPersistsAcrossInstances) +{ + const QString sub = MakeSubDir(QStringLiteral("persist_clear_flag")); + ASSERT_FALSE(sub.isEmpty()); + + const QString fn = QDir(sub).filePath(QStringLiteral("frame")); + ASSERT_TRUE(WriteFile(fn, 32)); + + { + olive::DiskCacheFolder folder(sub); + folder.SetClearOnClose(true); + folder.CreatedFile(fn); + // Destruction clears the cache and saves the flag into the index + } + + ASSERT_FALSE(QFileInfo::exists(fn)); + + { + olive::DiskCacheFolder reopened(sub); + EXPECT_TRUE(reopened.GetClearOnClose()); + + // The cleared entry must not come back through the index either + EXPECT_FALSE(reopened.DeleteSpecificFile(fn)); + } +} + +// Registering a file that does not exist on disk tracks it with size zero; +// deleting it again succeeds because a missing file counts as deleted. +TEST_F(RenderTailDiskCacheTest, CreatedFileForMissingFileIsTrackedAsZeroSize) +{ + const QString sub = MakeSubDir(QStringLiteral("zero_size")); + ASSERT_FALSE(sub.isEmpty()); + + olive::DiskCacheFolder folder(sub); + + const QString ghost = QDir(sub).filePath(QStringLiteral("ghost")); + ASSERT_FALSE(QFileInfo::exists(ghost)); + folder.CreatedFile(ghost); + + QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame); + + EXPECT_TRUE(folder.DeleteSpecificFile(ghost)); + + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(0).toString(), sub); + EXPECT_EQ(spy.first().at(1).toString(), ghost); +} + +// DiskManager::Accessed/CreatedFile forward to the matching folder and +// DeleteSpecificFile broadcasts to every open folder, re-emitting the folder's +// DeletedFrame signal as its own. +TEST_F(RenderTailDiskCacheTest, + DiskManagerAccessedAndDeleteSpecificFileForwardToFolder) +{ + olive::DiskManager *dm = olive::DiskManager::instance(); + ASSERT_NE(dm, nullptr); + + const QString sub = MakeSubDir(QStringLiteral("forwarding")); + ASSERT_FALSE(sub.isEmpty()); + + const QString fn = QDir(sub).filePath(QStringLiteral("frame")); + ASSERT_TRUE(WriteFile(fn, 32)); + + dm->CreatedFile(sub, fn); + dm->Accessed(sub, fn); + ASSERT_TRUE(QFileInfo::exists(fn)); + + QSignalSpy spy(dm, &olive::DiskManager::DeletedFrame); + + dm->DeleteSpecificFile(fn); + + EXPECT_FALSE(QFileInfo::exists(fn)); + ASSERT_EQ(spy.count(), 1); + EXPECT_EQ(spy.first().at(0).toString(), sub); + EXPECT_EQ(spy.first().at(1).toString(), fn); +} + +// The static path helpers must return distinct, non-empty locations; the config +// file name is part of the on-disk format. +TEST_F(RenderTailDiskCacheTest, DefaultDiskCachePathsAreNonEmptyAndDistinct) +{ + const QString config_file = + olive::DiskManager::GetDefaultDiskCacheConfigFile(); + const QString cache_path = olive::DiskManager::GetDefaultDiskCachePath(); + + EXPECT_FALSE(config_file.isEmpty()); + EXPECT_FALSE(cache_path.isEmpty()); + EXPECT_NE(config_file, cache_path); + EXPECT_TRUE(config_file.endsWith(QStringLiteral("defaultdiskcache"))); +} + +class RenderTailAudioCacheTest : public ::testing::Test { +protected: + void SetUp() override + { + if (!temp_dir_.isValid()) { + GTEST_FAIL() << "Failed to create temporary directory"; + } + + if (!olive::Core::instance()) { + new olive::Core(olive::Core::CoreParams()); // intentionally leaked + } + + olive::DiskManager::CreateInstance(); + + // Point the project cache at a folder alongside the (unsaved) project + // file so every cache write stays inside the temporary directory. + olive::ColorManager::SetUpDefaultConfig(); + project_ = std::make_unique(); + project_->Initialize(); + project_->set_filename( + QDir(temp_dir_.path()).filePath(QStringLiteral("test.ove"))); + project_->SetCacheLocationSetting( + olive::Project::kCacheStoreAlongsideProject); + } + + void TearDown() override + { + project_.reset(); + olive::DiskManager::DestroyInstance(); + } + + static olive::core::AudioParams MakeParams() + { + return olive::core::AudioParams(48000, + olive::core::kChannelLayoutStereo, + olive::core::SampleFormat::F32P); + } + + static void FillBuffer(olive::core::SampleBuffer *buf, float ch0, float ch1) + { + for (size_t i = 0; i < buf->sample_count(); i++) { + buf->data(0)[i] = ch0; + buf->data(1)[i] = ch1; + } + } + + QTemporaryDir temp_dir_; + std::unique_ptr project_; +}; + +// SetParameters stores the audio params; setting the same value twice early-outs +// and leaves them untouched. +TEST_F(RenderTailAudioCacheTest, SetParametersRoundTrip) +{ + olive::AudioPlaybackCache cache(project_.get()); + EXPECT_EQ(cache.GetParameters().channel_count(), 0); + + const olive::core::AudioParams params = MakeParams(); + cache.SetParameters(params); + EXPECT_EQ(cache.GetParameters(), params); + + cache.SetParameters(params); + EXPECT_EQ(cache.GetParameters(), params); + + const olive::core::AudioParams other(44100, + olive::core::kChannelLayoutMono, + olive::core::SampleFormat::F32P); + cache.SetParameters(other); + EXPECT_EQ(cache.GetParameters().sample_rate(), 44100); + EXPECT_EQ(cache.GetParameters().channel_count(), 1); +} + +// WritePCM writes one segment file per channel, zero-padded to the full segment +// size, and validates exactly the written range. +TEST_F(RenderTailAudioCacheTest, WritePcmWritesSegmentFilesAndValidatesRange) +{ + olive::AudioPlaybackCache cache(project_.get()); + cache.SetParameters(MakeParams()); + + const olive::TimeRange range(olive::rational(0), olive::rational(1, 10)); + + olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10)); + ASSERT_TRUE(buf.is_allocated()); + FillBuffer(&buf, 0.5f, 0.25f); + + cache.WritePCM(range, { range }, buf); + + EXPECT_TRUE(cache.HasValidatedRanges()); + EXPECT_FALSE(cache.HasInvalidatedRanges(range)); + + // 4800 samples of 4-byte floats per channel + const qint64 data_bytes = 19200; + + const QDir seg_dir = cache.GetThisCacheDirectory(); + const QString ch0 = seg_dir.filePath(QStringLiteral("0.0")); + const QString ch1 = seg_dir.filePath(QStringLiteral("0.1")); + ASSERT_TRUE(QFileInfo::exists(ch0)); + ASSERT_TRUE(QFileInfo::exists(ch1)); + + // The buffer covered the whole range, so no padding is needed and the + // segment contains exactly the written data + EXPECT_EQ(QFileInfo(ch0).size(), data_bytes); + EXPECT_EQ(QFileInfo(ch1).size(), data_bytes); + + QByteArray bytes; + ASSERT_TRUE(ReadBytesAt(ch0, 0, 4, &bytes)); + EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.5f); + + ASSERT_TRUE(ReadBytesAt(ch1, 0, 4, &bytes)); + EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.25f); +} + +// A write that does not start at zero must seek into the segment, leaving the +// preceding bytes as silence. +TEST_F(RenderTailAudioCacheTest, WritePcmAtNonZeroStartWritesAtByteOffset) +{ + olive::AudioPlaybackCache cache(project_.get()); + cache.SetParameters(MakeParams()); + + const olive::TimeRange range(olive::rational(1, 10), olive::rational(1, 5)); + + olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10)); + ASSERT_TRUE(buf.is_allocated()); + FillBuffer(&buf, 0.75f, 0.75f); + + cache.WritePCM(range, { range }, buf); + + EXPECT_FALSE(cache.HasInvalidatedRanges(range)); + + const qint64 data_bytes = 19200; + const QString ch0 = + cache.GetThisCacheDirectory().filePath(QStringLiteral("0.0")); + ASSERT_TRUE(QFileInfo::exists(ch0)); + // The file extends exactly to the end of the written range + EXPECT_EQ(QFileInfo(ch0).size(), 2 * data_bytes); + + // The first range was never written, so it reads back as silence + QByteArray bytes; + ASSERT_TRUE(ReadBytesAt(ch0, 0, 4, &bytes)); + EXPECT_EQ(bytes, QByteArray(4, '\0')); + + // The new data starts exactly at its byte offset + ASSERT_TRUE(ReadBytesAt(ch0, data_bytes, 4, &bytes)); + EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.75f); +} + +// Only the listed valid ranges are validated, even when the sample buffer +// covers the whole render range. +TEST_F(RenderTailAudioCacheTest, WritePcmWithPartialValidRangesValidatesOnlyThose) +{ + olive::AudioPlaybackCache cache(project_.get()); + cache.SetParameters(MakeParams()); + + const olive::TimeRange range(olive::rational(0), olive::rational(1, 5)); + const olive::TimeRange first_half(olive::rational(0), olive::rational(1, 10)); + + olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 5)); + ASSERT_TRUE(buf.is_allocated()); + FillBuffer(&buf, 0.5f, 0.5f); + + cache.WritePCM(range, { first_half }, buf); + + EXPECT_FALSE(cache.HasInvalidatedRanges(first_half)); + EXPECT_TRUE(cache.HasInvalidatedRanges(range)); +} + +// An empty valid-range list writes no segments and validates nothing. +TEST_F(RenderTailAudioCacheTest, WritePcmWithNoValidRangesWritesNothing) +{ + olive::AudioPlaybackCache cache(project_.get()); + cache.SetParameters(MakeParams()); + + const olive::TimeRange range(olive::rational(0), olive::rational(1, 10)); + + olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10)); + ASSERT_TRUE(buf.is_allocated()); + + cache.WritePCM(range, olive::TimeRangeList(), buf); + + EXPECT_FALSE(cache.HasValidatedRanges()); + EXPECT_FALSE(QFileInfo::exists( + cache.GetThisCacheDirectory().filePath(QStringLiteral("0.0")))); +} + +// A write larger than one segment must spill into the next segment file, with +// each touched segment zero-padded to its full extent. +TEST_F(RenderTailAudioCacheTest, + WritePcmSpanningSegmentBoundaryCreatesBothSegments) +{ + olive::AudioPlaybackCache cache(project_.get()); + cache.SetParameters(MakeParams()); + + // 56 seconds at 48000 Hz is 10752000 bytes per channel, just over one + // 10 MB segment. + const olive::TimeRange range(olive::rational(0), olive::rational(56)); + + olive::core::SampleBuffer buf(MakeParams(), olive::rational(56)); + ASSERT_TRUE(buf.is_allocated()); + ASSERT_EQ(buf.sample_count(), size_t(56 * 48000)); + FillBuffer(&buf, 1.0f, 1.0f); + + cache.WritePCM(range, { range }, buf); + + EXPECT_FALSE(cache.HasInvalidatedRanges(range)); + + const QDir seg_dir = cache.GetThisCacheDirectory(); + const QString seg0 = seg_dir.filePath(QStringLiteral("0.0")); + const QString seg1 = seg_dir.filePath(QStringLiteral("1.0")); + ASSERT_TRUE(QFileInfo::exists(seg0)); + ASSERT_TRUE(QFileInfo::exists(seg1)); + + EXPECT_EQ(QFileInfo(seg0).size(), kSegmentSize); + // The second segment holds exactly the spillover bytes + EXPECT_EQ(QFileInfo(seg1).size(), + 56 * 48000 * 4 - kSegmentSize); + + // The spillover data starts at the beginning of the second segment file + QByteArray bytes; + ASSERT_TRUE(ReadBytesAt(seg1, 0, 4, &bytes)); + EXPECT_FLOAT_EQ(BytesToFloat(bytes), 1.0f); +}