tests: coverage round 4 (footage, IPC, input immediate, project/factory)
- footage_test: static describe/loop-mode helpers, stream mapping, Value() job generation incl. proxy attachment, data roles, reprobe via seeded metadata cache - render_workerpool_ipc_test: SharedMemoryRegion, FrameSlotPool cross-mapping handoff, IpcMessage NDJSON round trips, RenderWorkerPool rejection paths - node_inputimmediate_test: NodeInputImmediate raw API, SetValueAtTime, interpolation (linear/hold/bezier) for float/vec/color/rational - project_factory_test: Project settings/cache modes/save-load/signals, NodeFactory creation and menus Also fixes Project::cache_path() returning the default cache instead of a configured custom cache path (inverted branch, found by the tests)
This commit is contained in:
@@ -478,7 +478,7 @@ QString Project::cache_path() const
|
|||||||
break;
|
break;
|
||||||
case kCacheCustomPath: {
|
case kCacheCustomPath: {
|
||||||
QString cache_path = GetCustomCachePath();
|
QString cache_path = GetCustomCachePath();
|
||||||
if (cache_path.isEmpty()) {
|
if (!cache_path.isEmpty()) {
|
||||||
return cache_path;
|
return cache_path;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ add_executable(olive-gtest
|
|||||||
node_core_test.cpp
|
node_core_test.cpp
|
||||||
clip_traverser_test.cpp
|
clip_traverser_test.cpp
|
||||||
audio_manager_viewer_test.cpp
|
audio_manager_viewer_test.cpp
|
||||||
|
footage_test.cpp
|
||||||
|
render_workerpool_ipc_test.cpp
|
||||||
|
node_inputimmediate_test.cpp
|
||||||
|
project_factory_test.cpp
|
||||||
timeline_marker_test.cpp
|
timeline_marker_test.cpp
|
||||||
undo_stack_test.cpp
|
undo_stack_test.cpp
|
||||||
plugin_support_test.cpp
|
plugin_support_test.cpp
|
||||||
|
|||||||
@@ -0,0 +1,809 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QStandardPaths>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QVariant>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QXmlStreamWriter>
|
||||||
|
|
||||||
|
#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 "render/diskmanager.h"
|
||||||
|
#include "node/project/footage/footagedescription.h"
|
||||||
|
#include "render/job/footagejob.h"
|
||||||
|
#include "render/loopmode.h"
|
||||||
|
#include "render/texture.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Footage subclass that exposes the protected stream mutators of ViewerOutput
|
||||||
|
// so tests can populate streams without probing real media
|
||||||
|
class TestableFootage : public olive::Footage {
|
||||||
|
public:
|
||||||
|
using olive::ViewerOutput::AddStream;
|
||||||
|
using olive::ViewerOutput::SetStream;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Temporarily overrides an environment variable, restoring the previous state
|
||||||
|
// on destruction. Used to sandbox the footage metadata cache inside a
|
||||||
|
// QTemporaryDir.
|
||||||
|
class ScopedEnvVar {
|
||||||
|
public:
|
||||||
|
ScopedEnvVar(const char *name, const QByteArray &value)
|
||||||
|
: name_(name)
|
||||||
|
, old_value_(qgetenv(name))
|
||||||
|
, had_value_(qEnvironmentVariableIsSet(name))
|
||||||
|
{
|
||||||
|
qputenv(name_, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
~ScopedEnvVar()
|
||||||
|
{
|
||||||
|
if (had_value_) {
|
||||||
|
qputenv(name_, old_value_);
|
||||||
|
} else {
|
||||||
|
qunsetenv(name_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
const char *name_;
|
||||||
|
QByteArray old_value_;
|
||||||
|
bool had_value_;
|
||||||
|
};
|
||||||
|
|
||||||
|
olive::VideoParams MakeVideoStream(int stream_index)
|
||||||
|
{
|
||||||
|
olive::VideoParams params(1920, 1080, olive::rational(1, 24),
|
||||||
|
olive::core::PixelFormat::U8, 4);
|
||||||
|
params.set_stream_index(stream_index);
|
||||||
|
params.set_duration(48); // 2 seconds at 24 fps
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::core::AudioParams MakeAudioStream(int stream_index)
|
||||||
|
{
|
||||||
|
olive::core::AudioParams params(48000, olive::core::kChannelLayoutStereo,
|
||||||
|
olive::core::SampleFormat::F32P);
|
||||||
|
params.set_stream_index(stream_index);
|
||||||
|
params.set_duration(96000); // 2 seconds at 48 kHz
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::SubtitleParams MakeSubtitleStream(int stream_index)
|
||||||
|
{
|
||||||
|
olive::SubtitleParams params;
|
||||||
|
params.set_stream_index(stream_index);
|
||||||
|
params.push_back(olive::Subtitle(
|
||||||
|
olive::TimeRange(olive::rational(0), olive::rational(3)),
|
||||||
|
QStringLiteral("subtitle text")));
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two video streams (one with an explicit colorspace, one without) and one
|
||||||
|
// audio stream, reported as three source streams in total
|
||||||
|
olive::FootageDescription MakeStandardDescription()
|
||||||
|
{
|
||||||
|
olive::FootageDescription desc(QStringLiteral("fakedecoder"));
|
||||||
|
|
||||||
|
olive::VideoParams video0 = MakeVideoStream(0);
|
||||||
|
desc.AddVideoStream(video0);
|
||||||
|
|
||||||
|
olive::VideoParams video1(1280, 720, olive::rational(1, 24),
|
||||||
|
olive::core::PixelFormat::U8, 4);
|
||||||
|
video1.set_stream_index(1);
|
||||||
|
video1.set_duration(48);
|
||||||
|
video1.set_colorspace(QStringLiteral("ExplicitSpace"));
|
||||||
|
desc.AddVideoStream(video1);
|
||||||
|
|
||||||
|
desc.AddAudioStream(MakeAudioStream(2));
|
||||||
|
|
||||||
|
desc.SetStreamCount(3);
|
||||||
|
return desc;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString CreateFakeMediaFile(QTemporaryDir &dir, const QString &name)
|
||||||
|
{
|
||||||
|
const QString path = QDir(dir.path()).filePath(name);
|
||||||
|
QFile file(path);
|
||||||
|
if (!file.open(QFile::WriteOnly)) {
|
||||||
|
return QString();
|
||||||
|
}
|
||||||
|
file.write("OAK_FAKE_MEDIA");
|
||||||
|
file.close();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seeds the footage metadata cache for media_path with desc, then points a
|
||||||
|
// project-parented Footage at the file so Reprobe() picks the cache up without
|
||||||
|
// running any real decoders. The caller is responsible for redirecting
|
||||||
|
// QStandardPaths::CacheLocation into a temporary directory first. Returns
|
||||||
|
// nullptr if the cache file could not be written.
|
||||||
|
TestableFootage *ProbeFootageFromCache(olive::Project *project,
|
||||||
|
const QString &media_path,
|
||||||
|
const olive::FootageDescription &desc)
|
||||||
|
{
|
||||||
|
const QString cache_location =
|
||||||
|
QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||||
|
if (cache_location.isEmpty() || !QDir().mkpath(cache_location)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString cache_file =
|
||||||
|
QDir(cache_location)
|
||||||
|
.filePath(olive::FileFunctions::GetUniqueFileIdentifier(media_path));
|
||||||
|
if (!desc.Save(cache_file)) {
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto *footage = new TestableFootage();
|
||||||
|
footage->setParent(project);
|
||||||
|
footage->set_filename(media_path);
|
||||||
|
return footage;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::NodeGlobals MakeGlobals(
|
||||||
|
olive::LoopMode loop_mode = olive::LoopMode::kLoopModeOff, int divider = 1)
|
||||||
|
{
|
||||||
|
olive::VideoParams vparams(64, 64, olive::rational(1, 24),
|
||||||
|
olive::core::PixelFormat::U8, 4);
|
||||||
|
vparams.set_divider(divider);
|
||||||
|
return olive::NodeGlobals(vparams, olive::core::AudioParams(),
|
||||||
|
olive::rational(0), loop_mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(FootageStatic, DescribeVideoStreamFormatsVideoAndStillStreams)
|
||||||
|
{
|
||||||
|
olive::VideoParams video = MakeVideoStream(0);
|
||||||
|
EXPECT_EQ(olive::Footage::DescribeVideoStream(video),
|
||||||
|
QStringLiteral("0: Video - 1920x1080"));
|
||||||
|
|
||||||
|
video.set_video_type(olive::VideoParams::kVideoTypeStill);
|
||||||
|
video.set_stream_index(3);
|
||||||
|
EXPECT_EQ(olive::Footage::DescribeVideoStream(video),
|
||||||
|
QStringLiteral("3: Image - 1920x1080"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, DescribeAudioStreamContainsIndexAndRate)
|
||||||
|
{
|
||||||
|
olive::core::AudioParams audio = MakeAudioStream(1);
|
||||||
|
|
||||||
|
// The %n plural marker is only substituted when a translation is loaded,
|
||||||
|
// so assert on the stable parts of the description instead
|
||||||
|
const QString description = olive::Footage::DescribeAudioStream(audio);
|
||||||
|
EXPECT_TRUE(description.startsWith(QStringLiteral("1: Audio")));
|
||||||
|
EXPECT_TRUE(description.contains(QStringLiteral("48000Hz")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, DescribeSubtitleStreamContainsIndex)
|
||||||
|
{
|
||||||
|
olive::SubtitleParams subs = MakeSubtitleStream(4);
|
||||||
|
EXPECT_EQ(olive::Footage::DescribeSubtitleStream(subs),
|
||||||
|
QStringLiteral("4: Subtitle"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, GetStreamTypeNameCoversAllTrackTypes)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kVideo),
|
||||||
|
QStringLiteral("Video"));
|
||||||
|
EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kAudio),
|
||||||
|
QStringLiteral("Audio"));
|
||||||
|
EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kSubtitle),
|
||||||
|
QStringLiteral("Subtitle"));
|
||||||
|
EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kNone),
|
||||||
|
QStringLiteral("Unknown"));
|
||||||
|
EXPECT_EQ(olive::Footage::GetStreamTypeName(olive::Track::kCount),
|
||||||
|
QStringLiteral("Unknown"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, AdjustTimeByLoopModeReturnsZeroForStillImages)
|
||||||
|
{
|
||||||
|
// Still images never loop, clamp, or drop: the adjusted time is always 0,
|
||||||
|
// even for in-bounds times
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(3), olive::LoopMode::kLoopModeOff,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeStill,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(0));
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(30), olive::LoopMode::kLoopModeLoop,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeStill,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(0));
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(-1), olive::LoopMode::kLoopModeClamp,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeStill,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, AdjustTimeByLoopModeKeepsInBoundsTime)
|
||||||
|
{
|
||||||
|
for (olive::LoopMode mode : { olive::LoopMode::kLoopModeOff,
|
||||||
|
olive::LoopMode::kLoopModeClamp,
|
||||||
|
olive::LoopMode::kLoopModeLoop }) {
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(3), mode, olive::rational(10),
|
||||||
|
olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, AdjustTimeByLoopModeOffDropsOutOfBoundsTime)
|
||||||
|
{
|
||||||
|
const olive::rational negative = olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(-1), olive::LoopMode::kLoopModeOff, olive::rational(10),
|
||||||
|
olive::VideoParams::kVideoTypeVideo, olive::rational(1, 24));
|
||||||
|
EXPECT_TRUE(negative.isNaN());
|
||||||
|
|
||||||
|
// The length itself is already out of bounds
|
||||||
|
const olive::rational at_length = olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(10), olive::LoopMode::kLoopModeOff, olive::rational(10),
|
||||||
|
olive::VideoParams::kVideoTypeVideo, olive::rational(1, 24));
|
||||||
|
EXPECT_TRUE(at_length.isNaN());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, AdjustTimeByLoopModeClampsToLength)
|
||||||
|
{
|
||||||
|
// Beyond the end, clamp to the last frame (length - timebase)
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(10), olive::LoopMode::kLoopModeClamp,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(239, 24));
|
||||||
|
|
||||||
|
// Before the start, clamp to 0
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(-5), olive::LoopMode::kLoopModeClamp,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, AdjustTimeByLoopModeLoopsAroundLength)
|
||||||
|
{
|
||||||
|
// Single wrap past the end
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(12), olive::LoopMode::kLoopModeLoop,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(2));
|
||||||
|
|
||||||
|
// Multiple wraps past the end
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(25), olive::LoopMode::kLoopModeLoop,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(5));
|
||||||
|
|
||||||
|
// Wraps from before the start
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(-3), olive::LoopMode::kLoopModeLoop,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(7));
|
||||||
|
EXPECT_EQ(olive::Footage::AdjustTimeByLoopMode(
|
||||||
|
olive::rational(-25), olive::LoopMode::kLoopModeLoop,
|
||||||
|
olive::rational(10), olive::VideoParams::kVideoTypeVideo,
|
||||||
|
olive::rational(1, 24)),
|
||||||
|
olive::rational(5));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FootageStatic, RetranslateSetsInputNames)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
footage.Retranslate();
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.GetInputName(olive::Footage::kFilenameInput),
|
||||||
|
QStringLiteral("Filename"));
|
||||||
|
EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kVideoParamsInput),
|
||||||
|
QStringLiteral("Video Parameters"));
|
||||||
|
EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kAudioParamsInput),
|
||||||
|
QStringLiteral("Audio Parameters"));
|
||||||
|
EXPECT_EQ(footage.GetInputName(olive::ViewerOutput::kSubtitleParamsInput),
|
||||||
|
QStringLiteral("Subtitle Parameters"));
|
||||||
|
}
|
||||||
|
|
||||||
|
class FootageTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
if (!olive::Core::instance()) {
|
||||||
|
// Leaked intentionally: Core is process-wide and DiskManager
|
||||||
|
// touches it (matches render_diskcache_test).
|
||||||
|
new olive::Core(olive::Core::CoreParams());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footage::Value() resolves Project::cache_path(), which goes through
|
||||||
|
// the DiskManager singleton
|
||||||
|
olive::DiskManager::CreateInstance();
|
||||||
|
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
|
||||||
|
project_ = std::make_unique<olive::Project>();
|
||||||
|
project_->Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
void TearDown() override
|
||||||
|
{
|
||||||
|
project_.reset();
|
||||||
|
olive::DiskManager::DestroyInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::Footage *AddFootage()
|
||||||
|
{
|
||||||
|
auto *footage = new olive::Footage();
|
||||||
|
footage->setParent(project_.get());
|
||||||
|
return footage;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Project> project_;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ManuallyAddedStreamsMapBetweenReferencesAndIndices)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.AddStream(olive::Track::kVideo,
|
||||||
|
QVariant::fromValue(MakeVideoStream(5))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(footage.AddStream(olive::Track::kAudio,
|
||||||
|
QVariant::fromValue(MakeAudioStream(2))),
|
||||||
|
0);
|
||||||
|
EXPECT_EQ(footage.AddStream(olive::Track::kSubtitle,
|
||||||
|
QVariant::fromValue(MakeSubtitleStream(7))),
|
||||||
|
0);
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kVideo, 0), 5);
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kAudio, 0), 2);
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kSubtitle, 0), 7);
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(
|
||||||
|
olive::Track::Reference(olive::Track::kVideo, 0)),
|
||||||
|
5);
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kNone, 0), -1);
|
||||||
|
EXPECT_EQ(footage.GetStreamIndex(olive::Track::kCount, 0), -1);
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.GetReferenceFromRealIndex(5),
|
||||||
|
olive::Track::Reference(olive::Track::kVideo, 0));
|
||||||
|
EXPECT_EQ(footage.GetReferenceFromRealIndex(2),
|
||||||
|
olive::Track::Reference(olive::Track::kAudio, 0));
|
||||||
|
EXPECT_EQ(footage.GetReferenceFromRealIndex(7),
|
||||||
|
olive::Track::Reference(olive::Track::kSubtitle, 0));
|
||||||
|
|
||||||
|
const olive::Track::Reference unknown =
|
||||||
|
footage.GetReferenceFromRealIndex(99);
|
||||||
|
EXPECT_EQ(unknown.type(), olive::Track::kNone);
|
||||||
|
EXPECT_EQ(unknown.index(), -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ConnectedOutputsReflectStreamTypes)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.GetConnectedTextureOutput(), nullptr);
|
||||||
|
EXPECT_EQ(footage.GetConnectedSampleOutput(), nullptr);
|
||||||
|
|
||||||
|
footage.AddStream(olive::Track::kVideo,
|
||||||
|
QVariant::fromValue(MakeVideoStream(0)));
|
||||||
|
EXPECT_EQ(footage.GetConnectedTextureOutput(),
|
||||||
|
static_cast<olive::Node *>(&footage));
|
||||||
|
EXPECT_EQ(footage.GetConnectedSampleOutput(), nullptr);
|
||||||
|
|
||||||
|
footage.AddStream(olive::Track::kAudio,
|
||||||
|
QVariant::fromValue(MakeAudioStream(1)));
|
||||||
|
EXPECT_EQ(footage.GetConnectedSampleOutput(),
|
||||||
|
static_cast<olive::Node *>(&footage));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, DataRolesForInvalidFootage)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
|
||||||
|
EXPECT_EQ(footage.data(olive::Node::TOOLTIP).toString(),
|
||||||
|
QStringLiteral("Invalid"));
|
||||||
|
EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
|
||||||
|
// With no existing file behind the footage, the time roles fall through
|
||||||
|
// to the base class and stay invalid
|
||||||
|
EXPECT_FALSE(footage.data(olive::Node::CREATED_TIME).isValid());
|
||||||
|
EXPECT_FALSE(footage.data(olive::Node::MODIFIED_TIME).isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, TooltipDescribesEnabledStreams)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
// The file does not exist, so the filename change clears the footage
|
||||||
|
// without probing anything
|
||||||
|
footage.set_filename(QStringLiteral("/nonexistent/media.mkv"));
|
||||||
|
footage.AddStream(olive::Track::kVideo,
|
||||||
|
QVariant::fromValue(MakeVideoStream(0)));
|
||||||
|
footage.AddStream(olive::Track::kAudio,
|
||||||
|
QVariant::fromValue(MakeAudioStream(1)));
|
||||||
|
footage.SetValid();
|
||||||
|
|
||||||
|
QString tip = footage.data(olive::Node::TOOLTIP).toString();
|
||||||
|
EXPECT_TRUE(
|
||||||
|
tip.contains(QStringLiteral("Filename: /nonexistent/media.mkv")));
|
||||||
|
EXPECT_TRUE(tip.contains(QStringLiteral("0: Video - 1920x1080")));
|
||||||
|
EXPECT_TRUE(tip.contains(QStringLiteral("Audio")));
|
||||||
|
|
||||||
|
// Disabled streams are omitted from the tooltip
|
||||||
|
olive::VideoParams disabled = footage.GetVideoParams(0);
|
||||||
|
disabled.set_enabled(false);
|
||||||
|
footage.SetStream(olive::Track::kVideo, QVariant::fromValue(disabled), 0);
|
||||||
|
|
||||||
|
tip = footage.data(olive::Node::TOOLTIP).toString();
|
||||||
|
EXPECT_FALSE(tip.contains(QStringLiteral("0: Video")));
|
||||||
|
EXPECT_TRUE(tip.contains(QStringLiteral("Audio")));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, IconReflectsPrioritizedStreamType)
|
||||||
|
{
|
||||||
|
// Invalid footage
|
||||||
|
TestableFootage footage;
|
||||||
|
EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
|
||||||
|
// Real video streams take priority over audio
|
||||||
|
footage.AddStream(olive::Track::kVideo,
|
||||||
|
QVariant::fromValue(MakeVideoStream(0)));
|
||||||
|
footage.AddStream(olive::Track::kAudio,
|
||||||
|
QVariant::fromValue(MakeAudioStream(1)));
|
||||||
|
footage.SetValid();
|
||||||
|
EXPECT_TRUE(footage.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
|
||||||
|
// A still image without audio hits the image branch
|
||||||
|
TestableFootage stills;
|
||||||
|
olive::VideoParams still = MakeVideoStream(0);
|
||||||
|
still.set_video_type(olive::VideoParams::kVideoTypeStill);
|
||||||
|
stills.AddStream(olive::Track::kVideo, QVariant::fromValue(still));
|
||||||
|
stills.SetValid();
|
||||||
|
EXPECT_TRUE(stills.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
|
||||||
|
// Audio-only footage
|
||||||
|
TestableFootage audio_only;
|
||||||
|
audio_only.AddStream(olive::Track::kAudio,
|
||||||
|
QVariant::fromValue(MakeAudioStream(0)));
|
||||||
|
audio_only.SetValid();
|
||||||
|
EXPECT_TRUE(audio_only.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
|
||||||
|
// Subtitle-only footage
|
||||||
|
TestableFootage subs_only;
|
||||||
|
subs_only.AddStream(olive::Track::kSubtitle,
|
||||||
|
QVariant::fromValue(MakeSubtitleStream(0)));
|
||||||
|
subs_only.SetValid();
|
||||||
|
EXPECT_TRUE(subs_only.data(olive::Node::ICON).canConvert<QIcon>());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ProxyChangesMarkProjectModifiedAndEmitSignal)
|
||||||
|
{
|
||||||
|
olive::Footage *footage = AddFootage();
|
||||||
|
ASSERT_FALSE(project_->is_modified());
|
||||||
|
|
||||||
|
int emissions = 0;
|
||||||
|
QObject::connect(footage, &olive::Footage::ProxySettingsChanged,
|
||||||
|
[&emissions]() { ++emissions; });
|
||||||
|
|
||||||
|
footage->SetProxy(QStringLiteral("/cache/proxy/example.mp4"),
|
||||||
|
olive::ProxyManager::kProxyReady, 0, 1, true);
|
||||||
|
EXPECT_EQ(emissions, 1);
|
||||||
|
EXPECT_TRUE(project_->is_modified());
|
||||||
|
|
||||||
|
// set_proxy_enabled only reacts to actual changes
|
||||||
|
project_->set_modified(false);
|
||||||
|
footage->set_proxy_enabled(true);
|
||||||
|
EXPECT_EQ(emissions, 1);
|
||||||
|
EXPECT_FALSE(project_->is_modified());
|
||||||
|
|
||||||
|
footage->set_proxy_enabled(false);
|
||||||
|
EXPECT_EQ(emissions, 2);
|
||||||
|
EXPECT_TRUE(project_->is_modified());
|
||||||
|
EXPECT_FALSE(footage->proxy_enabled());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ReprobeRestoresStreamsFromMetadataCache)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const ScopedEnvVar xdg(
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8());
|
||||||
|
|
||||||
|
const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv"));
|
||||||
|
ASSERT_FALSE(media.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage *footage = ProbeFootageFromCache(
|
||||||
|
project_.get(), media, MakeStandardDescription());
|
||||||
|
ASSERT_NE(footage, nullptr);
|
||||||
|
|
||||||
|
EXPECT_TRUE(footage->IsValid());
|
||||||
|
EXPECT_EQ(footage->decoder(), QStringLiteral("fakedecoder"));
|
||||||
|
EXPECT_EQ(footage->GetTotalStreamCount(), 3);
|
||||||
|
EXPECT_EQ(footage->GetVideoStreamCount(), 2);
|
||||||
|
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::kVideo, 1), 1);
|
||||||
|
EXPECT_EQ(footage->GetStreamIndex(olive::Track::kAudio, 0), 2);
|
||||||
|
EXPECT_EQ(footage->GetReferenceFromRealIndex(2),
|
||||||
|
olive::Track::Reference(olive::Track::kAudio, 0));
|
||||||
|
|
||||||
|
EXPECT_EQ(footage->GetConnectedTextureOutput(),
|
||||||
|
static_cast<olive::Node *>(footage));
|
||||||
|
EXPECT_EQ(footage->GetConnectedSampleOutput(),
|
||||||
|
static_cast<olive::Node *>(footage));
|
||||||
|
|
||||||
|
// The file behind the footage exists, so both time roles are reported
|
||||||
|
const QVariant modified = footage->data(olive::Node::MODIFIED_TIME);
|
||||||
|
ASSERT_TRUE(modified.isValid());
|
||||||
|
EXPECT_EQ(modified.toLongLong(),
|
||||||
|
QFileInfo(media).lastModified().toSecsSinceEpoch());
|
||||||
|
EXPECT_TRUE(footage->data(olive::Node::CREATED_TIME).isValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, VerifyLengthUsesStreamDurations)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const ScopedEnvVar xdg(
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8());
|
||||||
|
|
||||||
|
const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv"));
|
||||||
|
ASSERT_FALSE(media.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage *footage = ProbeFootageFromCache(
|
||||||
|
project_.get(), media, MakeStandardDescription());
|
||||||
|
ASSERT_NE(footage, nullptr);
|
||||||
|
|
||||||
|
// Both streams describe two seconds of media
|
||||||
|
footage->VerifyLength();
|
||||||
|
EXPECT_EQ(footage->GetVideoLength(), olive::rational(2));
|
||||||
|
EXPECT_EQ(footage->GetAudioLength(), olive::rational(2));
|
||||||
|
EXPECT_EQ(footage->GetLength(), olive::rational(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ValueSkipsMissingFiles)
|
||||||
|
{
|
||||||
|
TestableFootage footage;
|
||||||
|
|
||||||
|
olive::NodeValueRow row;
|
||||||
|
row.insert(olive::Footage::kFilenameInput,
|
||||||
|
olive::NodeValue(olive::NodeValue::kFile,
|
||||||
|
QStringLiteral("/nonexistent/media.mkv")));
|
||||||
|
|
||||||
|
olive::NodeValueTable table;
|
||||||
|
footage.Value(row, MakeGlobals(), &table);
|
||||||
|
|
||||||
|
EXPECT_TRUE(table.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ValuePushesOnlyLengthWhenNoStreams)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const QString media = CreateFakeMediaFile(dir, QStringLiteral("empty.mkv"));
|
||||||
|
ASSERT_FALSE(media.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage footage;
|
||||||
|
|
||||||
|
olive::NodeValueRow row;
|
||||||
|
row.insert(olive::Footage::kFilenameInput,
|
||||||
|
olive::NodeValue(olive::NodeValue::kFile, media));
|
||||||
|
|
||||||
|
olive::NodeValueTable table;
|
||||||
|
footage.Value(row, MakeGlobals(), &table);
|
||||||
|
|
||||||
|
// The file exists but no streams were ever probed, so only the (zero)
|
||||||
|
// length is pushed
|
||||||
|
ASSERT_EQ(table.Count(), 1);
|
||||||
|
const olive::NodeValue length =
|
||||||
|
table.Get(olive::NodeValue::kRational, QStringLiteral("length"));
|
||||||
|
EXPECT_EQ(length.type(), olive::NodeValue::kRational);
|
||||||
|
EXPECT_EQ(length.toRational(), olive::rational(0));
|
||||||
|
EXPECT_EQ(length.source(), static_cast<const olive::Node *>(&footage));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ValuePushesStreamJobs)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const ScopedEnvVar xdg(
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8());
|
||||||
|
|
||||||
|
const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv"));
|
||||||
|
ASSERT_FALSE(media.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage *footage = ProbeFootageFromCache(
|
||||||
|
project_.get(), media, MakeStandardDescription());
|
||||||
|
ASSERT_NE(footage, nullptr);
|
||||||
|
footage->VerifyLength();
|
||||||
|
|
||||||
|
// The colorspace fallback reads the project default, and the audio cache
|
||||||
|
// path comes from the project's cache settings
|
||||||
|
project_->SetDefaultInputColorSpace(QStringLiteral("TestInputSpace"));
|
||||||
|
project_->SetCacheLocationSetting(olive::Project::kCacheCustomPath);
|
||||||
|
const QString cache_path =
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("cache"));
|
||||||
|
project_->SetCustomCachePath(cache_path);
|
||||||
|
|
||||||
|
olive::NodeValueRow row;
|
||||||
|
row.insert(olive::Footage::kFilenameInput,
|
||||||
|
olive::NodeValue(olive::NodeValue::kFile, media));
|
||||||
|
|
||||||
|
// A divider > 1 routes through the target-resolution divider calculation
|
||||||
|
const olive::NodeGlobals globals =
|
||||||
|
MakeGlobals(olive::LoopMode::kLoopModeLoop, 2);
|
||||||
|
|
||||||
|
olive::NodeValueTable table;
|
||||||
|
footage->Value(row, globals, &table);
|
||||||
|
|
||||||
|
// Length, two texture jobs, and one sample job
|
||||||
|
EXPECT_EQ(table.Count(), 4);
|
||||||
|
|
||||||
|
const olive::NodeValue length =
|
||||||
|
table.Get(olive::NodeValue::kRational, QStringLiteral("length"));
|
||||||
|
EXPECT_EQ(length.toRational(), olive::rational(2));
|
||||||
|
|
||||||
|
// A stream without a colorspace falls back to the project default
|
||||||
|
const olive::TexturePtr tex0 =
|
||||||
|
table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(tex0, nullptr);
|
||||||
|
EXPECT_EQ(tex0->params().colorspace(), QStringLiteral("TestInputSpace"));
|
||||||
|
// min(calculated divider for 32x32 from 1920x1080, requested 2)
|
||||||
|
EXPECT_EQ(tex0->params().divider(), 2);
|
||||||
|
|
||||||
|
// An explicit colorspace survives
|
||||||
|
const olive::TexturePtr tex1 =
|
||||||
|
table.Get(olive::NodeValue::kTexture, QStringLiteral("v:1"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(tex1, nullptr);
|
||||||
|
EXPECT_EQ(tex1->params().colorspace(), QStringLiteral("ExplicitSpace"));
|
||||||
|
|
||||||
|
const olive::NodeValue samples =
|
||||||
|
table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0"));
|
||||||
|
ASSERT_EQ(samples.type(), olive::NodeValue::kSamples);
|
||||||
|
const olive::FootageJob audio_job =
|
||||||
|
samples.data().value<olive::FootageJob>();
|
||||||
|
EXPECT_EQ(audio_job.filename(), media);
|
||||||
|
EXPECT_EQ(audio_job.decoder(), QStringLiteral("fakedecoder"));
|
||||||
|
EXPECT_EQ(audio_job.type(), olive::Track::kAudio);
|
||||||
|
EXPECT_EQ(audio_job.audio_params().sample_rate(), 48000);
|
||||||
|
EXPECT_EQ(audio_job.length(), olive::rational(2));
|
||||||
|
EXPECT_EQ(audio_job.loop_mode(), olive::LoopMode::kLoopModeLoop);
|
||||||
|
EXPECT_EQ(audio_job.time(), globals.time());
|
||||||
|
EXPECT_EQ(audio_job.cache_path(), cache_path);
|
||||||
|
|
||||||
|
// With a divider of 1, everything renders at full resolution
|
||||||
|
olive::NodeValueTable full_res;
|
||||||
|
footage->Value(row, MakeGlobals(), &full_res);
|
||||||
|
const olive::TexturePtr full_res_tex =
|
||||||
|
full_res.Get(olive::NodeValue::kTexture, QStringLiteral("v:0"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(full_res_tex, nullptr);
|
||||||
|
EXPECT_EQ(full_res_tex->params().divider(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, ValueAttachesReadyProxyToJobs)
|
||||||
|
{
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const ScopedEnvVar xdg(
|
||||||
|
"XDG_CACHE_HOME",
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("xdg")).toUtf8());
|
||||||
|
|
||||||
|
const QString media = CreateFakeMediaFile(dir, QStringLiteral("fake.mkv"));
|
||||||
|
ASSERT_FALSE(media.isEmpty());
|
||||||
|
|
||||||
|
TestableFootage *footage = ProbeFootageFromCache(
|
||||||
|
project_.get(), media, MakeStandardDescription());
|
||||||
|
ASSERT_NE(footage, nullptr);
|
||||||
|
footage->VerifyLength();
|
||||||
|
|
||||||
|
// A ready proxy is simply an existing proxy file with no .working
|
||||||
|
// sibling; the .a1. marker declares that it contains audio
|
||||||
|
const QString proxy = QDir(dir.path())
|
||||||
|
.filePath(QStringLiteral(
|
||||||
|
"proxy-0.1920x1080.v1.a1.mp4"));
|
||||||
|
{
|
||||||
|
QFile proxy_file(proxy);
|
||||||
|
ASSERT_TRUE(proxy_file.open(QFile::WriteOnly));
|
||||||
|
}
|
||||||
|
footage->SetProxy(proxy, olive::ProxyManager::kProxyReady, 0, 1, true);
|
||||||
|
|
||||||
|
olive::NodeValueRow row;
|
||||||
|
row.insert(olive::Footage::kFilenameInput,
|
||||||
|
olive::NodeValue(olive::NodeValue::kFile, media));
|
||||||
|
|
||||||
|
olive::NodeValueTable table;
|
||||||
|
footage->Value(row, MakeGlobals(), &table);
|
||||||
|
|
||||||
|
// The proxied video stream (real index 0) gets the proxy at stream 0
|
||||||
|
const olive::TexturePtr tex0 =
|
||||||
|
table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(tex0, nullptr);
|
||||||
|
const auto *video0_job =
|
||||||
|
static_cast<const olive::FootageJob *>(tex0->job());
|
||||||
|
ASSERT_NE(video0_job, nullptr);
|
||||||
|
EXPECT_TRUE(video0_job->has_proxy());
|
||||||
|
EXPECT_EQ(video0_job->proxy_filename(), proxy);
|
||||||
|
EXPECT_EQ(video0_job->proxy_decoder(), QStringLiteral("ffmpeg"));
|
||||||
|
EXPECT_EQ(video0_job->proxy_stream_index(), 0);
|
||||||
|
|
||||||
|
// Other video streams are unaffected
|
||||||
|
const olive::TexturePtr tex1 =
|
||||||
|
table.Get(olive::NodeValue::kTexture, QStringLiteral("v:1"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(tex1, nullptr);
|
||||||
|
const auto *video1_job =
|
||||||
|
static_cast<const olive::FootageJob *>(tex1->job());
|
||||||
|
ASSERT_NE(video1_job, nullptr);
|
||||||
|
EXPECT_FALSE(video1_job->has_proxy());
|
||||||
|
|
||||||
|
// The first audio stream follows the video stream inside the proxy file
|
||||||
|
const olive::FootageJob audio_job =
|
||||||
|
table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0"))
|
||||||
|
.data()
|
||||||
|
.value<olive::FootageJob>();
|
||||||
|
EXPECT_TRUE(audio_job.has_proxy());
|
||||||
|
EXPECT_EQ(audio_job.proxy_filename(), proxy);
|
||||||
|
EXPECT_EQ(audio_job.proxy_stream_index(), 1);
|
||||||
|
|
||||||
|
// Disabling the proxy detaches it from subsequent jobs
|
||||||
|
footage->set_proxy_enabled(false);
|
||||||
|
olive::NodeValueTable no_proxy;
|
||||||
|
footage->Value(row, MakeGlobals(), &no_proxy);
|
||||||
|
const olive::TexturePtr no_proxy_tex =
|
||||||
|
no_proxy.Get(olive::NodeValue::kTexture, QStringLiteral("v:0"))
|
||||||
|
.toTexture();
|
||||||
|
ASSERT_NE(no_proxy_tex, nullptr);
|
||||||
|
const auto *no_proxy_job =
|
||||||
|
static_cast<const olive::FootageJob *>(no_proxy_tex->job());
|
||||||
|
ASSERT_NE(no_proxy_job, nullptr);
|
||||||
|
EXPECT_FALSE(no_proxy_job->has_proxy());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(FootageTest, SaveCustomPersistsSourceStartTime)
|
||||||
|
{
|
||||||
|
olive::Footage footage;
|
||||||
|
footage.set_timestamp(7);
|
||||||
|
footage.SetSourceStartTime(olive::rational(3600), QStringLiteral("manual"));
|
||||||
|
|
||||||
|
QString xml;
|
||||||
|
QXmlStreamWriter writer(&xml);
|
||||||
|
writer.writeStartDocument();
|
||||||
|
writer.writeStartElement(QStringLiteral("custom"));
|
||||||
|
footage.SaveCustom(&writer);
|
||||||
|
writer.writeEndElement();
|
||||||
|
writer.writeEndDocument();
|
||||||
|
|
||||||
|
EXPECT_TRUE(xml.contains(QStringLiteral("sourcestarttime")));
|
||||||
|
EXPECT_TRUE(xml.contains(QStringLiteral("source=\"manual\"")));
|
||||||
|
EXPECT_TRUE(xml.contains(QStringLiteral("3600/1")));
|
||||||
|
|
||||||
|
QXmlStreamReader reader(xml);
|
||||||
|
ASSERT_TRUE(reader.readNextStartElement());
|
||||||
|
ASSERT_EQ(reader.name(), QStringLiteral("custom"));
|
||||||
|
|
||||||
|
olive::Footage loaded;
|
||||||
|
ASSERT_TRUE(loaded.LoadCustom(&reader, nullptr));
|
||||||
|
ASSERT_TRUE(loaded.HasSourceStartTime());
|
||||||
|
EXPECT_EQ(loaded.source_start_time(), olive::rational(3600));
|
||||||
|
EXPECT_EQ(loaded.source_start_time_source(), QStringLiteral("manual"));
|
||||||
|
EXPECT_EQ(loaded.timestamp(), 7);
|
||||||
|
}
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <Imath/ImathVec.h>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QVector2D>
|
||||||
|
|
||||||
|
#include "node/color/colormanager/colormanager.h"
|
||||||
|
#include "node/generator/matrix/matrix.h"
|
||||||
|
#include "node/generator/solid/solid.h"
|
||||||
|
#include "node/inputimmediate.h"
|
||||||
|
#include "node/keyframe.h"
|
||||||
|
#include "node/math/math/math.h"
|
||||||
|
#include "node/node.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/time/timeoffset/timeoffsetnode.h"
|
||||||
|
#include "olive/core/util/bezier.h"
|
||||||
|
#include "undo/undocommand.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// NodeInputImmediate is not a QObject, so keyframes inserted directly into it
|
||||||
|
// (rather than through parenting to a Node) must be removed and deleted by
|
||||||
|
// hand to avoid leaks.
|
||||||
|
void ClearImmediate(olive::NodeInputImmediate *imm)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < imm->keyframe_tracks().size(); i++) {
|
||||||
|
const QVector<olive::NodeKeyframe *> keys = imm->keyframe_tracks().at(i);
|
||||||
|
for (olive::NodeKeyframe *key : keys) {
|
||||||
|
imm->remove_keyframe(key);
|
||||||
|
delete key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::NodeKeyframe *MakeKey(const olive::rational &time, const QVariant &value,
|
||||||
|
int track,
|
||||||
|
olive::NodeKeyframe::Type type =
|
||||||
|
olive::NodeKeyframe::kLinear)
|
||||||
|
{
|
||||||
|
return new olive::NodeKeyframe(time, value, type, track, -1,
|
||||||
|
QStringLiteral("test_in"));
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, StandardValueRoundTrip)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 1.5 });
|
||||||
|
|
||||||
|
EXPECT_FALSE(imm.is_keyframing());
|
||||||
|
EXPECT_TRUE(imm.is_using_standard_value(0));
|
||||||
|
|
||||||
|
// The default value seeds the standard value
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value().at(0).toDouble(), 1.5);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 1.5);
|
||||||
|
|
||||||
|
imm.set_split_standard_value({ 2.5 });
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value().at(0).toDouble(), 2.5);
|
||||||
|
|
||||||
|
imm.set_standard_value_on_track(3.5, 0);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 3.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, SetSplitStandardValueCopiesOnlyOverlappingTracks)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 });
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 2);
|
||||||
|
|
||||||
|
// A shorter split only overwrites the tracks it covers
|
||||||
|
imm.set_split_standard_value({ 5.0 });
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 5.0);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(1).toDouble(), 0.0);
|
||||||
|
|
||||||
|
// A longer split is clamped to the existing track count
|
||||||
|
imm.set_split_standard_value({ 1.0, 2.0, 3.0 });
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 2);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(1).toDouble(), 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, SetDataTypeResizesTracksAndReappliesDefault)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 7.0 });
|
||||||
|
ASSERT_EQ(imm.keyframe_tracks().size(), 1);
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 1);
|
||||||
|
|
||||||
|
// Growing to a four-track type keeps the default on the first track and
|
||||||
|
// leaves the new tracks null, since the default split only has one entry
|
||||||
|
imm.set_data_type(olive::NodeValue::kVec4);
|
||||||
|
EXPECT_EQ(imm.keyframe_tracks().size(), 4);
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 4);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 7.0);
|
||||||
|
EXPECT_TRUE(imm.get_split_standard_value_on_track(1).isNull());
|
||||||
|
EXPECT_TRUE(imm.get_split_standard_value_on_track(2).isNull());
|
||||||
|
EXPECT_TRUE(imm.get_split_standard_value_on_track(3).isNull());
|
||||||
|
|
||||||
|
imm.set_data_type(olive::NodeValue::kFloat);
|
||||||
|
EXPECT_EQ(imm.keyframe_tracks().size(), 1);
|
||||||
|
ASSERT_EQ(imm.get_split_standard_value().size(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(imm.get_split_standard_value_on_track(0).toDouble(), 7.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, KeyframeLookupRequiresKeyframingEnabled)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 });
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key = MakeKey(olive::rational(2), 1.0, 0);
|
||||||
|
imm.insert_keyframe(key);
|
||||||
|
|
||||||
|
// Without keyframing enabled the track reports that it uses the standard
|
||||||
|
// value and all keyframe lookups come back empty
|
||||||
|
EXPECT_TRUE(imm.is_using_standard_value(0));
|
||||||
|
EXPECT_FALSE(imm.has_keyframe_at_time(olive::rational(2)));
|
||||||
|
EXPECT_EQ(imm.get_keyframe_at_time_on_track(olive::rational(2), 0),
|
||||||
|
nullptr);
|
||||||
|
EXPECT_TRUE(imm.get_keyframe_at_time(olive::rational(2)).isEmpty());
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(2), 0),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
imm.set_is_keyframing(true);
|
||||||
|
EXPECT_FALSE(imm.is_using_standard_value(0));
|
||||||
|
EXPECT_TRUE(imm.has_keyframe_at_time(olive::rational(2)));
|
||||||
|
EXPECT_EQ(imm.get_keyframe_at_time_on_track(olive::rational(2), 0), key);
|
||||||
|
ASSERT_EQ(imm.get_keyframe_at_time(olive::rational(2)).size(), 1);
|
||||||
|
EXPECT_EQ(imm.get_keyframe_at_time(olive::rational(2)).first(), key);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(2), 0),
|
||||||
|
key);
|
||||||
|
EXPECT_FALSE(imm.has_keyframe_at_time(olive::rational(3)));
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, InsertKeyframeSortsByTimeAndLinksSiblings)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 });
|
||||||
|
|
||||||
|
// Insert out of order; the track must stay sorted by time
|
||||||
|
olive::NodeKeyframe *key_late = MakeKey(olive::rational(10), 10.0, 0);
|
||||||
|
olive::NodeKeyframe *key_early = MakeKey(olive::rational(0), 0.0, 0);
|
||||||
|
olive::NodeKeyframe *key_mid = MakeKey(olive::rational(5), 5.0, 0);
|
||||||
|
imm.insert_keyframe(key_late);
|
||||||
|
imm.insert_keyframe(key_early);
|
||||||
|
imm.insert_keyframe(key_mid);
|
||||||
|
|
||||||
|
const olive::NodeKeyframeTrack &track = imm.keyframe_tracks().at(0);
|
||||||
|
ASSERT_EQ(track.size(), 3);
|
||||||
|
EXPECT_EQ(track.at(0), key_early);
|
||||||
|
EXPECT_EQ(track.at(1), key_mid);
|
||||||
|
EXPECT_EQ(track.at(2), key_late);
|
||||||
|
|
||||||
|
// Sibling links follow the sorted order
|
||||||
|
EXPECT_EQ(key_early->previous(), nullptr);
|
||||||
|
EXPECT_EQ(key_early->next(), key_mid);
|
||||||
|
EXPECT_EQ(key_mid->previous(), key_early);
|
||||||
|
EXPECT_EQ(key_mid->next(), key_late);
|
||||||
|
EXPECT_EQ(key_late->previous(), key_mid);
|
||||||
|
EXPECT_EQ(key_late->next(), nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(imm.get_earliest_keyframe(), key_early);
|
||||||
|
EXPECT_EQ(imm.get_latest_keyframe(), key_late);
|
||||||
|
|
||||||
|
// Removing the middle keyframe re-links its siblings
|
||||||
|
imm.remove_keyframe(key_mid);
|
||||||
|
EXPECT_EQ(key_early->next(), key_late);
|
||||||
|
EXPECT_EQ(key_late->previous(), key_early);
|
||||||
|
EXPECT_EQ(key_mid->previous(), nullptr);
|
||||||
|
EXPECT_EQ(key_mid->next(), nullptr);
|
||||||
|
ASSERT_EQ(track.size(), 2);
|
||||||
|
delete key_mid;
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, ClosestKeyframeToTimeOnTrackClampsAndPicksNearest)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 });
|
||||||
|
imm.set_is_keyframing(true);
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key_a = MakeKey(olive::rational(0), 0.0, 0);
|
||||||
|
olive::NodeKeyframe *key_b = MakeKey(olive::rational(10), 10.0, 0);
|
||||||
|
imm.insert_keyframe(key_a);
|
||||||
|
imm.insert_keyframe(key_b);
|
||||||
|
|
||||||
|
// Outside the keyed range the closest keyframe clamps to the ends
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(-3), 0),
|
||||||
|
key_a);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(20), 0),
|
||||||
|
key_b);
|
||||||
|
|
||||||
|
// Between the keys the nearer one wins
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(3), 0),
|
||||||
|
key_a);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(7), 0),
|
||||||
|
key_b);
|
||||||
|
|
||||||
|
// Exactly halfway the earlier keyframe wins the tie
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(5), 0),
|
||||||
|
key_a);
|
||||||
|
|
||||||
|
// A track with no keyframes still counts as using the standard value
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_to_time_on_track(olive::rational(5), 1),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, ClosestKeyframeBeforeAfterSpansAllTracks)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 });
|
||||||
|
imm.set_is_keyframing(true);
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key_t0 = MakeKey(olive::rational(0), 0.0, 0);
|
||||||
|
olive::NodeKeyframe *key_t10 = MakeKey(olive::rational(10), 10.0, 0);
|
||||||
|
olive::NodeKeyframe *key_t4_track1 = MakeKey(olive::rational(4), 4.0, 1);
|
||||||
|
imm.insert_keyframe(key_t0);
|
||||||
|
imm.insert_keyframe(key_t10);
|
||||||
|
imm.insert_keyframe(key_t4_track1);
|
||||||
|
|
||||||
|
// The closest keyframe before 5 is the one at 4 on the other track
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(5)),
|
||||||
|
key_t4_track1);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(5)), key_t10);
|
||||||
|
|
||||||
|
// Strictly before/after: nothing exists outside the keyed range
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(0)),
|
||||||
|
nullptr);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(10)),
|
||||||
|
nullptr);
|
||||||
|
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_before_time(olive::rational(4)), key_t0);
|
||||||
|
EXPECT_EQ(imm.get_closest_keyframe_after_time(olive::rational(4)), key_t10);
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, BestKeyframeTypeForTimeFollowsClosestKey)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kFloat, { 0.0 });
|
||||||
|
|
||||||
|
// With no keyframes there is no reference, so the default type is used
|
||||||
|
EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(5), 0)),
|
||||||
|
int(olive::NodeKeyframe::kDefaultType));
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key_hold =
|
||||||
|
MakeKey(olive::rational(0), 0.0, 0, olive::NodeKeyframe::kHold);
|
||||||
|
olive::NodeKeyframe *key_linear = MakeKey(olive::rational(10), 10.0, 0);
|
||||||
|
imm.insert_keyframe(key_hold);
|
||||||
|
imm.insert_keyframe(key_linear);
|
||||||
|
imm.set_is_keyframing(true);
|
||||||
|
|
||||||
|
EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(2), 0)),
|
||||||
|
int(olive::NodeKeyframe::kHold));
|
||||||
|
EXPECT_EQ(int(imm.get_best_keyframe_type_for_time(olive::rational(8), 0)),
|
||||||
|
int(olive::NodeKeyframe::kLinear));
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeInputImmediate, GetKeyframeAtTimeAggregatesAcrossTracks)
|
||||||
|
{
|
||||||
|
olive::NodeInputImmediate imm(olive::NodeValue::kVec2, { 0.0, 0.0 });
|
||||||
|
imm.set_is_keyframing(true);
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key_track0 = MakeKey(olive::rational(3), 1.0, 0);
|
||||||
|
olive::NodeKeyframe *key_track1 = MakeKey(olive::rational(3), 2.0, 1);
|
||||||
|
olive::NodeKeyframe *key_later = MakeKey(olive::rational(7), 3.0, 0);
|
||||||
|
imm.insert_keyframe(key_track0);
|
||||||
|
imm.insert_keyframe(key_track1);
|
||||||
|
imm.insert_keyframe(key_later);
|
||||||
|
|
||||||
|
// Both tracks have a keyframe at t=3
|
||||||
|
QVector<olive::NodeKeyframe *> at_three =
|
||||||
|
imm.get_keyframe_at_time(olive::rational(3));
|
||||||
|
ASSERT_EQ(at_three.size(), 2);
|
||||||
|
EXPECT_TRUE(at_three.contains(key_track0));
|
||||||
|
EXPECT_TRUE(at_three.contains(key_track1));
|
||||||
|
|
||||||
|
// Only track 0 has one at t=7, and there is nothing at t=99
|
||||||
|
EXPECT_EQ(imm.get_keyframe_at_time(olive::rational(7)).size(), 1);
|
||||||
|
EXPECT_TRUE(imm.get_keyframe_at_time(olive::rational(99)).isEmpty());
|
||||||
|
|
||||||
|
ClearImmediate(&imm);
|
||||||
|
}
|
||||||
|
|
||||||
|
class NodeInputImmediateNodeTest : public ::testing::Test {
|
||||||
|
protected:
|
||||||
|
void SetUp() override
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
|
||||||
|
project_ = std::make_unique<olive::Project>();
|
||||||
|
project_->Initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
template <typename T> T *AddNode()
|
||||||
|
{
|
||||||
|
T *node = new T();
|
||||||
|
node->setParent(project_.get());
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::NodeKeyframe *AddKey(olive::Node *node, const QString &input,
|
||||||
|
const olive::rational &time,
|
||||||
|
const QVariant &value, int track,
|
||||||
|
olive::NodeKeyframe::Type type =
|
||||||
|
olive::NodeKeyframe::kLinear)
|
||||||
|
{
|
||||||
|
auto *key = new olive::NodeKeyframe(time, value, type, track, -1, input);
|
||||||
|
key->setParent(node);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Project> project_;
|
||||||
|
};
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeCreatesAndUpdatesKeyframes)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
const olive::NodeInput input(node, olive::MathNode::kParamAIn);
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
|
||||||
|
// A new keyframe is inserted where none exists yet
|
||||||
|
olive::MultiUndoCommand cmd;
|
||||||
|
olive::Node::SetValueAtTime(input, olive::rational(5), 42.0, 0, &cmd, true);
|
||||||
|
EXPECT_EQ(cmd.child_count(), 1);
|
||||||
|
cmd.redo_now();
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key = node->GetKeyframeAtTimeOnTrack(
|
||||||
|
olive::MathNode::kParamAIn, olive::rational(5), 0);
|
||||||
|
ASSERT_NE(key, nullptr);
|
||||||
|
EXPECT_DOUBLE_EQ(key->value().toDouble(), 42.0);
|
||||||
|
EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn,
|
||||||
|
olive::rational(5))
|
||||||
|
.toDouble(),
|
||||||
|
42.0);
|
||||||
|
|
||||||
|
// Setting the same time again updates the existing keyframe in place
|
||||||
|
olive::MultiUndoCommand update_cmd;
|
||||||
|
olive::Node::SetValueAtTime(input, olive::rational(5), 43.0, 0,
|
||||||
|
&update_cmd, true);
|
||||||
|
EXPECT_EQ(update_cmd.child_count(), 1);
|
||||||
|
update_cmd.redo_now();
|
||||||
|
|
||||||
|
const QVector<olive::NodeKeyframeTrack> &tracks =
|
||||||
|
node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1);
|
||||||
|
ASSERT_EQ(tracks.at(0).size(), 1);
|
||||||
|
EXPECT_EQ(tracks.at(0).first(), key);
|
||||||
|
EXPECT_DOUBLE_EQ(key->value().toDouble(), 43.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeWithoutKeyframingSetsStandardValue)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
const olive::NodeInput input(node, olive::MathNode::kParamAIn);
|
||||||
|
|
||||||
|
olive::MultiUndoCommand cmd;
|
||||||
|
olive::Node::SetValueAtTime(input, olive::rational(5), 9.0, 0, &cmd, true);
|
||||||
|
EXPECT_EQ(cmd.child_count(), 1);
|
||||||
|
cmd.redo_now();
|
||||||
|
|
||||||
|
EXPECT_DOUBLE_EQ(
|
||||||
|
node->GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 9.0);
|
||||||
|
EXPECT_TRUE(node->GetKeyframeTracks(olive::MathNode::kParamAIn, -1)
|
||||||
|
.at(0)
|
||||||
|
.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, SetValueAtTimeInsertsOnAllTracksOnlyWhenAsked)
|
||||||
|
{
|
||||||
|
auto *solid = AddNode<olive::SolidGenerator>();
|
||||||
|
solid->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true);
|
||||||
|
const olive::NodeInput input(solid, olive::SolidGenerator::kColorInput);
|
||||||
|
|
||||||
|
// With insert_on_all_tracks_if_no_key set, keyframes are created on every
|
||||||
|
// track; sibling tracks capture the value they currently evaluate to (the
|
||||||
|
// standard value red = (1, 0, 0, 1) here)
|
||||||
|
olive::MultiUndoCommand cmd;
|
||||||
|
olive::Node::SetValueAtTime(input, olive::rational(5), 0.5, 2, &cmd, true);
|
||||||
|
EXPECT_EQ(cmd.child_count(), 4);
|
||||||
|
cmd.redo_now();
|
||||||
|
|
||||||
|
const QVector<olive::NodeKeyframeTrack> &tracks =
|
||||||
|
solid->GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1);
|
||||||
|
ASSERT_EQ(tracks.size(), 4);
|
||||||
|
for (int i = 0; i < tracks.size(); i++) {
|
||||||
|
ASSERT_EQ(tracks.at(i).size(), 1);
|
||||||
|
EXPECT_EQ(tracks.at(i).first()->time(), olive::rational(5));
|
||||||
|
}
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(0).first()->value().toDouble(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(1).first()->value().toDouble(), 0.0);
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(2).first()->value().toDouble(), 0.5);
|
||||||
|
EXPECT_DOUBLE_EQ(tracks.at(3).first()->value().toDouble(), 1.0);
|
||||||
|
|
||||||
|
const olive::Color c =
|
||||||
|
solid->GetValueAtTime(olive::SolidGenerator::kColorInput,
|
||||||
|
olive::rational(5))
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(c.red(), 1.0f);
|
||||||
|
EXPECT_FLOAT_EQ(c.green(), 0.0f);
|
||||||
|
EXPECT_FLOAT_EQ(c.blue(), 0.5f);
|
||||||
|
EXPECT_FLOAT_EQ(c.alpha(), 1.0f);
|
||||||
|
|
||||||
|
// Without the flag only the requested track receives a keyframe
|
||||||
|
auto *single = AddNode<olive::SolidGenerator>();
|
||||||
|
single->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true);
|
||||||
|
const olive::NodeInput single_input(single,
|
||||||
|
olive::SolidGenerator::kColorInput);
|
||||||
|
|
||||||
|
olive::MultiUndoCommand single_cmd;
|
||||||
|
olive::Node::SetValueAtTime(single_input, olive::rational(5), 0.5, 2,
|
||||||
|
&single_cmd, false);
|
||||||
|
EXPECT_EQ(single_cmd.child_count(), 1);
|
||||||
|
single_cmd.redo_now();
|
||||||
|
|
||||||
|
const QVector<olive::NodeKeyframeTrack> &single_tracks =
|
||||||
|
single->GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1);
|
||||||
|
ASSERT_EQ(single_tracks.size(), 4);
|
||||||
|
EXPECT_TRUE(single_tracks.at(0).isEmpty());
|
||||||
|
EXPECT_TRUE(single_tracks.at(1).isEmpty());
|
||||||
|
ASSERT_EQ(single_tracks.at(2).size(), 1);
|
||||||
|
EXPECT_DOUBLE_EQ(single_tracks.at(2).first()->value().toDouble(), 0.5);
|
||||||
|
EXPECT_TRUE(single_tracks.at(3).isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesColorTracks)
|
||||||
|
{
|
||||||
|
auto *solid = AddNode<olive::SolidGenerator>();
|
||||||
|
solid->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true);
|
||||||
|
|
||||||
|
// Black to white over ten seconds on all four tracks
|
||||||
|
for (int track = 0; track < 4; track++) {
|
||||||
|
AddKey(solid, olive::SolidGenerator::kColorInput, olive::rational(0),
|
||||||
|
0.0, track);
|
||||||
|
AddKey(solid, olive::SolidGenerator::kColorInput, olive::rational(10),
|
||||||
|
1.0, track);
|
||||||
|
}
|
||||||
|
|
||||||
|
const olive::Color mid =
|
||||||
|
solid->GetValueAtTime(olive::SolidGenerator::kColorInput,
|
||||||
|
olive::rational(5))
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(mid.red(), 0.5f);
|
||||||
|
EXPECT_FLOAT_EQ(mid.green(), 0.5f);
|
||||||
|
EXPECT_FLOAT_EQ(mid.blue(), 0.5f);
|
||||||
|
EXPECT_FLOAT_EQ(mid.alpha(), 0.5f);
|
||||||
|
|
||||||
|
const olive::SplitValue split = solid->GetSplitValueAtTime(
|
||||||
|
olive::SolidGenerator::kColorInput, olive::rational(5));
|
||||||
|
ASSERT_EQ(split.size(), 4);
|
||||||
|
for (int i = 0; i < split.size(); i++) {
|
||||||
|
EXPECT_DOUBLE_EQ(split.at(i).toDouble(), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Outside the keyed range the end values hold
|
||||||
|
const olive::Color before =
|
||||||
|
solid->GetValueAtTime(olive::SolidGenerator::kColorInput,
|
||||||
|
olive::rational(-2))
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(before.red(), 0.0f);
|
||||||
|
const olive::Color after =
|
||||||
|
solid->GetValueAtTime(olive::SolidGenerator::kColorInput,
|
||||||
|
olive::rational(20))
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(after.alpha(), 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesVec2Tracks)
|
||||||
|
{
|
||||||
|
auto *matrix = AddNode<olive::MatrixGenerator>();
|
||||||
|
matrix->SetInputIsKeyframing(olive::MatrixGenerator::kPositionInput, true);
|
||||||
|
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0),
|
||||||
|
0.0, 0);
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10),
|
||||||
|
10.0, 0);
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0),
|
||||||
|
10.0, 1);
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10),
|
||||||
|
20.0, 1);
|
||||||
|
|
||||||
|
const QVector2D mid =
|
||||||
|
matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput,
|
||||||
|
olive::rational(5))
|
||||||
|
.value<QVector2D>();
|
||||||
|
EXPECT_FLOAT_EQ(mid.x(), 5.0f);
|
||||||
|
EXPECT_FLOAT_EQ(mid.y(), 15.0f);
|
||||||
|
|
||||||
|
// Each track clamps to its own end keyframes
|
||||||
|
const QVector2D clamped_low =
|
||||||
|
matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput,
|
||||||
|
olive::rational(-5))
|
||||||
|
.value<QVector2D>();
|
||||||
|
EXPECT_FLOAT_EQ(clamped_low.x(), 0.0f);
|
||||||
|
EXPECT_FLOAT_EQ(clamped_low.y(), 10.0f);
|
||||||
|
const QVector2D clamped_high =
|
||||||
|
matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput,
|
||||||
|
olive::rational(15))
|
||||||
|
.value<QVector2D>();
|
||||||
|
EXPECT_FLOAT_EQ(clamped_high.x(), 10.0f);
|
||||||
|
EXPECT_FLOAT_EQ(clamped_high.y(), 20.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeInterpolatesRationalAsRational)
|
||||||
|
{
|
||||||
|
auto *offset = AddNode<olive::TimeOffsetNode>();
|
||||||
|
offset->SetInputIsKeyframing(olive::TimeOffsetNode::kTimeInput, true);
|
||||||
|
|
||||||
|
AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(0),
|
||||||
|
QVariant::fromValue(olive::rational(0)), 0);
|
||||||
|
AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(10),
|
||||||
|
QVariant::fromValue(olive::rational(10)), 0);
|
||||||
|
|
||||||
|
// The interpolated value is converted back into a rational
|
||||||
|
const QVariant mid = offset->GetValueAtTime(
|
||||||
|
olive::TimeOffsetNode::kTimeInput, olive::rational(5));
|
||||||
|
EXPECT_EQ(mid.value<olive::rational>(), olive::rational(5));
|
||||||
|
|
||||||
|
const QVariant one_tenth_in = offset->GetValueAtTime(
|
||||||
|
olive::TimeOffsetNode::kTimeInput, olive::rational(1));
|
||||||
|
EXPECT_DOUBLE_EQ(one_tenth_in.value<olive::rational>().toDouble(), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeHoldsRationalUntilNextKey)
|
||||||
|
{
|
||||||
|
auto *offset = AddNode<olive::TimeOffsetNode>();
|
||||||
|
offset->SetInputIsKeyframing(olive::TimeOffsetNode::kTimeInput, true);
|
||||||
|
|
||||||
|
AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(0),
|
||||||
|
QVariant::fromValue(olive::rational(2, 3)), 0,
|
||||||
|
olive::NodeKeyframe::kHold);
|
||||||
|
AddKey(offset, olive::TimeOffsetNode::kTimeInput, olive::rational(10),
|
||||||
|
QVariant::fromValue(olive::rational(4, 3)), 0);
|
||||||
|
|
||||||
|
// A hold keyframe keeps its exact rational value until the next key
|
||||||
|
const QVariant held = offset->GetValueAtTime(
|
||||||
|
olive::TimeOffsetNode::kTimeInput, olive::rational(9));
|
||||||
|
EXPECT_EQ(held.value<olive::rational>(), olive::rational(2, 3));
|
||||||
|
|
||||||
|
const QVariant at_next = offset->GetValueAtTime(
|
||||||
|
olive::TimeOffsetNode::kTimeInput, olive::rational(10));
|
||||||
|
EXPECT_EQ(at_next.value<olive::rational>(), olive::rational(4, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeBezierHandlesBendCurve)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
|
||||||
|
olive::NodeKeyframe *before =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 0.0, 0,
|
||||||
|
olive::NodeKeyframe::kBezier);
|
||||||
|
olive::NodeKeyframe *after =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(10), 10.0, 0,
|
||||||
|
olive::NodeKeyframe::kBezier);
|
||||||
|
|
||||||
|
// Ease-in shape: the outgoing handle pulls the start of the curve flat
|
||||||
|
before->set_bezier_control_out(QPointF(2.5, 0.0));
|
||||||
|
after->set_bezier_control_in(QPointF(0.0, 0.0));
|
||||||
|
|
||||||
|
const double interpolated = node->GetValueAtTime(
|
||||||
|
olive::MathNode::kParamAIn, olive::rational(5)).toDouble();
|
||||||
|
const double expected = olive::core::Bezier::CubicXtoY(
|
||||||
|
5.0, Imath::V2d(0.0, 0.0), Imath::V2d(2.5, 0.0), Imath::V2d(10.0, 10.0),
|
||||||
|
Imath::V2d(10.0, 10.0));
|
||||||
|
EXPECT_DOUBLE_EQ(interpolated, expected);
|
||||||
|
|
||||||
|
// The ease-in handle keeps the midpoint below the linear value of 5.0
|
||||||
|
EXPECT_LT(interpolated, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, GetValueAtTimeQuadraticBezierWithOneHandle)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
|
||||||
|
// Bezier into linear uses a quadratic curve with a single control point
|
||||||
|
olive::NodeKeyframe *before =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 0.0, 0,
|
||||||
|
olive::NodeKeyframe::kBezier);
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(10), 10.0, 0,
|
||||||
|
olive::NodeKeyframe::kLinear);
|
||||||
|
before->set_bezier_control_out(QPointF(2.5, 0.0));
|
||||||
|
|
||||||
|
const double interpolated = node->GetValueAtTime(
|
||||||
|
olive::MathNode::kParamAIn, olive::rational(5)).toDouble();
|
||||||
|
const double expected = olive::core::Bezier::QuadraticXtoY(
|
||||||
|
5.0, Imath::V2d(0.0, 0.0), Imath::V2d(2.5, 0.0),
|
||||||
|
Imath::V2d(10.0, 10.0));
|
||||||
|
EXPECT_DOUBLE_EQ(interpolated, expected);
|
||||||
|
EXPECT_LT(interpolated, 5.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, IsUsingStandardValueTransitions)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
node->SetStandardValue(olive::MathNode::kParamAIn, 3.0);
|
||||||
|
|
||||||
|
// Static input: standard value is always in use
|
||||||
|
EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
|
||||||
|
// Keyframing enabled but no keyframes yet: still the standard value
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
|
||||||
|
// With a keyframe present the track switches to the keyed value
|
||||||
|
olive::NodeKeyframe *key =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(5), 7.0, 0);
|
||||||
|
EXPECT_FALSE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
|
||||||
|
// Disabling keyframing hides the keyframes again
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, false);
|
||||||
|
EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn,
|
||||||
|
olive::rational(5))
|
||||||
|
.toDouble(),
|
||||||
|
3.0);
|
||||||
|
|
||||||
|
// Removing the last keyframe returns the track to the standard value
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
EXPECT_FALSE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
key->setParent(nullptr);
|
||||||
|
delete key;
|
||||||
|
EXPECT_TRUE(node->IsUsingStandardValue(olive::MathNode::kParamAIn, 0));
|
||||||
|
EXPECT_DOUBLE_EQ(node->GetValueAtTime(olive::MathNode::kParamAIn,
|
||||||
|
olive::rational(5))
|
||||||
|
.toDouble(),
|
||||||
|
3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, PartiallyKeyedTrackFallsBackToStandardValue)
|
||||||
|
{
|
||||||
|
auto *matrix = AddNode<olive::MatrixGenerator>();
|
||||||
|
matrix->SetStandardValue(olive::MatrixGenerator::kPositionInput,
|
||||||
|
QVector2D(1.0f, 2.0f));
|
||||||
|
matrix->SetInputIsKeyframing(olive::MatrixGenerator::kPositionInput, true);
|
||||||
|
|
||||||
|
// Only the X track is keyed; the Y track keeps its standard value
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(0),
|
||||||
|
0.0, 0);
|
||||||
|
AddKey(matrix, olive::MatrixGenerator::kPositionInput, olive::rational(10),
|
||||||
|
10.0, 0);
|
||||||
|
|
||||||
|
EXPECT_FALSE(
|
||||||
|
matrix->IsUsingStandardValue(olive::MatrixGenerator::kPositionInput,
|
||||||
|
0));
|
||||||
|
EXPECT_TRUE(
|
||||||
|
matrix->IsUsingStandardValue(olive::MatrixGenerator::kPositionInput,
|
||||||
|
1));
|
||||||
|
|
||||||
|
const QVector2D value =
|
||||||
|
matrix->GetValueAtTime(olive::MatrixGenerator::kPositionInput,
|
||||||
|
olive::rational(5))
|
||||||
|
.value<QVector2D>();
|
||||||
|
EXPECT_FLOAT_EQ(value.x(), 5.0f);
|
||||||
|
EXPECT_FLOAT_EQ(value.y(), 2.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, StandardValueCombinationAcrossTracks)
|
||||||
|
{
|
||||||
|
auto *solid = AddNode<olive::SolidGenerator>();
|
||||||
|
|
||||||
|
// The declared default is opaque red
|
||||||
|
olive::Color initial =
|
||||||
|
solid->GetStandardValue(olive::SolidGenerator::kColorInput)
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(initial.red(), 1.0f);
|
||||||
|
EXPECT_FLOAT_EQ(initial.green(), 0.0f);
|
||||||
|
EXPECT_FLOAT_EQ(initial.blue(), 0.0f);
|
||||||
|
EXPECT_FLOAT_EQ(initial.alpha(), 1.0f);
|
||||||
|
|
||||||
|
// Setting a normal value splits it across the four tracks
|
||||||
|
solid->SetStandardValue(
|
||||||
|
olive::SolidGenerator::kColorInput,
|
||||||
|
QVariant::fromValue(olive::Color(0.25f, 0.5f, 0.75f, 1.0f)));
|
||||||
|
const olive::SplitValue split =
|
||||||
|
solid->GetSplitStandardValue(olive::SolidGenerator::kColorInput);
|
||||||
|
ASSERT_EQ(split.size(), 4);
|
||||||
|
EXPECT_DOUBLE_EQ(split.at(0).toDouble(), 0.25);
|
||||||
|
EXPECT_DOUBLE_EQ(split.at(1).toDouble(), 0.5);
|
||||||
|
EXPECT_DOUBLE_EQ(split.at(2).toDouble(), 0.75);
|
||||||
|
EXPECT_DOUBLE_EQ(split.at(3).toDouble(), 1.0);
|
||||||
|
EXPECT_DOUBLE_EQ(solid->GetSplitStandardValueOnTrack(
|
||||||
|
olive::SolidGenerator::kColorInput, 2)
|
||||||
|
.toDouble(),
|
||||||
|
0.75);
|
||||||
|
|
||||||
|
// A partial split only overwrites the leading tracks
|
||||||
|
solid->SetSplitStandardValue(olive::SolidGenerator::kColorInput,
|
||||||
|
{ 0.1, 0.2 });
|
||||||
|
const olive::Color combined =
|
||||||
|
solid->GetStandardValue(olive::SolidGenerator::kColorInput)
|
||||||
|
.value<olive::Color>();
|
||||||
|
EXPECT_FLOAT_EQ(combined.red(), 0.1f);
|
||||||
|
EXPECT_FLOAT_EQ(combined.green(), 0.2f);
|
||||||
|
EXPECT_FLOAT_EQ(combined.blue(), 0.75f);
|
||||||
|
EXPECT_FLOAT_EQ(combined.alpha(), 1.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST_F(NodeInputImmediateNodeTest, DeleteAllKeyframesReparentsOrDeletes)
|
||||||
|
{
|
||||||
|
auto *node = AddNode<olive::MathNode>();
|
||||||
|
node->SetInputIsKeyframing(olive::MathNode::kParamAIn, true);
|
||||||
|
|
||||||
|
olive::NodeKeyframe *key_a =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(0), 1.0, 0);
|
||||||
|
olive::NodeKeyframe *key_b =
|
||||||
|
AddKey(node, olive::MathNode::kParamAIn, olive::rational(1), 2.0, 0);
|
||||||
|
|
||||||
|
olive::NodeInputImmediate *imm =
|
||||||
|
node->GetImmediate(olive::MathNode::kParamAIn, -1);
|
||||||
|
ASSERT_NE(imm, nullptr);
|
||||||
|
ASSERT_EQ(imm->keyframe_tracks().at(0).size(), 2);
|
||||||
|
|
||||||
|
// With a parent the keyframes are handed over rather than deleted
|
||||||
|
QObject guard;
|
||||||
|
imm->delete_all_keyframes(&guard);
|
||||||
|
EXPECT_TRUE(imm->keyframe_tracks().at(0).isEmpty());
|
||||||
|
EXPECT_EQ(guard.children().size(), 2);
|
||||||
|
EXPECT_TRUE(guard.children().contains(key_a));
|
||||||
|
EXPECT_TRUE(guard.children().contains(key_b));
|
||||||
|
|
||||||
|
// Without a parent the keyframes are deleted outright
|
||||||
|
key_a->setParent(node);
|
||||||
|
key_b->setParent(node);
|
||||||
|
ASSERT_EQ(imm->keyframe_tracks().at(0).size(), 2);
|
||||||
|
imm->delete_all_keyframes();
|
||||||
|
EXPECT_TRUE(imm->keyframe_tracks().at(0).isEmpty());
|
||||||
|
}
|
||||||
@@ -0,0 +1,587 @@
|
|||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
#include <QAction>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QList>
|
||||||
|
#include <QMenu>
|
||||||
|
#include <QPointF>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QUuid>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QXmlStreamWriter>
|
||||||
|
|
||||||
|
#include "node/block/clip/clip.h"
|
||||||
|
#include "node/block/gap/gap.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/group/group.h"
|
||||||
|
#include "node/math/math/math.h"
|
||||||
|
#include "node/output/track/track.h"
|
||||||
|
#include "node/output/viewer/viewer.h"
|
||||||
|
#include "node/project.h"
|
||||||
|
#include "node/project/folder/folder.h"
|
||||||
|
#include "node/project/footage/footage.h"
|
||||||
|
#include "node/project/sequence/sequence.h"
|
||||||
|
#include "node/serializeddata.h"
|
||||||
|
#include "core.h"
|
||||||
|
#include "render/diskmanager.h"
|
||||||
|
#include "widget/menu/menu.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
void CollectLeafActions(QMenu *menu, QList<QAction *> *leaves)
|
||||||
|
{
|
||||||
|
for (QAction *action : menu->actions()) {
|
||||||
|
if (action->menu()) {
|
||||||
|
CollectLeafActions(action->menu(), leaves);
|
||||||
|
} else if (!action->isSeparator()) {
|
||||||
|
leaves->append(action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Project save/load and cache paths go through 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(Project, FilenameNamePrettyAndSignals)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
int name_changes = 0;
|
||||||
|
QObject::connect(&project, &olive::Project::NameChanged,
|
||||||
|
[&name_changes]() { ++name_changes; });
|
||||||
|
|
||||||
|
const QString filename = QStringLiteral("/tmp/some/dir/my_edit.ove");
|
||||||
|
project.set_filename(filename);
|
||||||
|
EXPECT_EQ(project.filename(), filename);
|
||||||
|
EXPECT_EQ(project.name(), QStringLiteral("my_edit"));
|
||||||
|
EXPECT_EQ(project.pretty_filename(), filename);
|
||||||
|
EXPECT_FALSE(project.is_new());
|
||||||
|
EXPECT_EQ(name_changes, 1);
|
||||||
|
|
||||||
|
// Each set_filename() call emits, even for the same value
|
||||||
|
project.set_filename(filename);
|
||||||
|
EXPECT_EQ(name_changes, 2);
|
||||||
|
|
||||||
|
project.SetSavedURL(QStringLiteral("/tmp/some/dir"));
|
||||||
|
EXPECT_EQ(project.GetSavedURL(), QStringLiteral("/tmp/some/dir"));
|
||||||
|
|
||||||
|
// A filename alone does not mark the project modified
|
||||||
|
EXPECT_FALSE(project.is_modified());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, ModifiedAndAutoRecoverySignals)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
QVector<bool> states;
|
||||||
|
QObject::connect(&project, &olive::Project::ModifiedChanged,
|
||||||
|
[&states](bool e) { states.append(e); });
|
||||||
|
|
||||||
|
project.set_modified(true);
|
||||||
|
EXPECT_TRUE(project.is_modified());
|
||||||
|
EXPECT_FALSE(project.has_autorecovery_been_saved());
|
||||||
|
|
||||||
|
project.set_modified(false);
|
||||||
|
EXPECT_FALSE(project.is_modified());
|
||||||
|
EXPECT_TRUE(project.has_autorecovery_been_saved());
|
||||||
|
|
||||||
|
ASSERT_EQ(states.size(), 2);
|
||||||
|
EXPECT_TRUE(states.at(0));
|
||||||
|
EXPECT_FALSE(states.at(1));
|
||||||
|
|
||||||
|
// The auto-recovery flag can also be controlled directly
|
||||||
|
project.set_autorecovery_saved(false);
|
||||||
|
EXPECT_FALSE(project.has_autorecovery_been_saved());
|
||||||
|
project.set_autorecovery_saved(true);
|
||||||
|
EXPECT_TRUE(project.has_autorecovery_been_saved());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, SettingsEmitSignalsAndColorSideEffects)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project project;
|
||||||
|
|
||||||
|
QVector<QString> changed_keys;
|
||||||
|
QObject::connect(&project, &olive::Project::SettingChanged,
|
||||||
|
[&changed_keys](const QString &key, const QString &) {
|
||||||
|
changed_keys.append(key);
|
||||||
|
});
|
||||||
|
|
||||||
|
QString reference_space;
|
||||||
|
QObject::connect(project.color_manager(),
|
||||||
|
&olive::ColorManager::ReferenceSpaceChanged,
|
||||||
|
[&reference_space](const QString &s) {
|
||||||
|
reference_space = s;
|
||||||
|
});
|
||||||
|
QString default_input;
|
||||||
|
QObject::connect(project.color_manager(),
|
||||||
|
&olive::ColorManager::DefaultInputChanged,
|
||||||
|
[&default_input](const QString &s) { default_input = s; });
|
||||||
|
|
||||||
|
project.SetSetting(QStringLiteral("plain"), QStringLiteral("value"));
|
||||||
|
EXPECT_EQ(project.GetSetting(QStringLiteral("plain")),
|
||||||
|
QStringLiteral("value"));
|
||||||
|
|
||||||
|
project.SetColorReferenceSpace(QStringLiteral("ACES - ACEScg"));
|
||||||
|
EXPECT_EQ(project.GetColorReferenceSpace(),
|
||||||
|
QStringLiteral("ACES - ACEScg"));
|
||||||
|
EXPECT_EQ(reference_space, QStringLiteral("ACES - ACEScg"));
|
||||||
|
|
||||||
|
project.SetDefaultInputColorSpace(QStringLiteral("Linear Rec.709"));
|
||||||
|
EXPECT_EQ(project.GetDefaultInputColorSpace(),
|
||||||
|
QStringLiteral("Linear Rec.709"));
|
||||||
|
EXPECT_EQ(default_input, QStringLiteral("Linear Rec.709"));
|
||||||
|
|
||||||
|
// A nonexistent config filename is stored; the failed OCIO load inside
|
||||||
|
// ColorManager::UpdateConfigFromFilename() is swallowed
|
||||||
|
project.SetColorConfigFilename(QStringLiteral("/nonexistent/config.ocio"));
|
||||||
|
EXPECT_EQ(project.GetColorConfigFilename(),
|
||||||
|
QStringLiteral("/nonexistent/config.ocio"));
|
||||||
|
|
||||||
|
EXPECT_TRUE(changed_keys.contains(QStringLiteral("plain")));
|
||||||
|
EXPECT_TRUE(changed_keys.contains(olive::Project::kColorReferenceSpace));
|
||||||
|
EXPECT_TRUE(
|
||||||
|
changed_keys.contains(olive::Project::kDefaultInputColorSpaceKey));
|
||||||
|
EXPECT_TRUE(changed_keys.contains(olive::Project::kColorConfigFilename));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, CachePathModes)
|
||||||
|
{
|
||||||
|
const bool created_disk_manager =
|
||||||
|
(olive::DiskManager::instance() == nullptr);
|
||||||
|
if (created_disk_manager) {
|
||||||
|
olive::DiskManager::CreateInstance();
|
||||||
|
}
|
||||||
|
const QString default_path =
|
||||||
|
olive::DiskManager::instance()->GetDefaultCachePath();
|
||||||
|
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const QString filename =
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("proj.ove"));
|
||||||
|
const QString alongside =
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("cache"));
|
||||||
|
|
||||||
|
olive::Project project;
|
||||||
|
project.set_filename(filename);
|
||||||
|
|
||||||
|
// Default mode always returns the application-wide cache path
|
||||||
|
project.SetCacheLocationSetting(olive::Project::kCacheUseDefaultLocation);
|
||||||
|
EXPECT_EQ(project.GetCacheLocationSetting(),
|
||||||
|
olive::Project::kCacheUseDefaultLocation);
|
||||||
|
EXPECT_EQ(project.cache_path(), default_path);
|
||||||
|
|
||||||
|
// Alongside mode returns a "cache" directory next to the project file
|
||||||
|
project.SetCacheLocationSetting(
|
||||||
|
olive::Project::kCacheStoreAlongsideProject);
|
||||||
|
EXPECT_EQ(project.get_cache_alongside_project_path(), alongside);
|
||||||
|
EXPECT_EQ(project.cache_path(), alongside);
|
||||||
|
|
||||||
|
// Without a filename there is no alongside location, so it falls back
|
||||||
|
olive::Project unsaved;
|
||||||
|
unsaved.SetCacheLocationSetting(
|
||||||
|
olive::Project::kCacheStoreAlongsideProject);
|
||||||
|
EXPECT_TRUE(unsaved.get_cache_alongside_project_path().isEmpty());
|
||||||
|
EXPECT_EQ(unsaved.cache_path(), default_path);
|
||||||
|
|
||||||
|
// A non-empty custom path is used verbatim; an empty one falls back to
|
||||||
|
// the default location (this branch used to be inverted)
|
||||||
|
olive::Project custom;
|
||||||
|
custom.SetCacheLocationSetting(olive::Project::kCacheCustomPath);
|
||||||
|
custom.SetCustomCachePath(QStringLiteral("/tmp/oak-custom-cache"));
|
||||||
|
EXPECT_EQ(custom.GetCustomCachePath(),
|
||||||
|
QStringLiteral("/tmp/oak-custom-cache"));
|
||||||
|
EXPECT_EQ(custom.cache_path(), QStringLiteral("/tmp/oak-custom-cache"));
|
||||||
|
|
||||||
|
olive::Project custom_empty;
|
||||||
|
custom_empty.SetCacheLocationSetting(olive::Project::kCacheCustomPath);
|
||||||
|
EXPECT_EQ(custom_empty.cache_path(), default_path);
|
||||||
|
|
||||||
|
if (created_disk_manager) {
|
||||||
|
olive::DiskManager::DestroyInstance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, NodeManagementSignalsAndClear)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project project;
|
||||||
|
project.Initialize();
|
||||||
|
|
||||||
|
// The root folder created by Initialize() is part of the graph
|
||||||
|
ASSERT_EQ(project.nodes().size(), 1);
|
||||||
|
EXPECT_EQ(project.nodes().first(), project.root());
|
||||||
|
|
||||||
|
int added = 0;
|
||||||
|
int removed = 0;
|
||||||
|
olive::Node *last_added = nullptr;
|
||||||
|
QObject::connect(&project, &olive::Project::NodeAdded,
|
||||||
|
[&added, &last_added](olive::Node *n) {
|
||||||
|
++added;
|
||||||
|
last_added = n;
|
||||||
|
});
|
||||||
|
QObject::connect(&project, &olive::Project::NodeRemoved,
|
||||||
|
[&removed](olive::Node *) { ++removed; });
|
||||||
|
|
||||||
|
auto *math = new olive::MathNode();
|
||||||
|
math->setParent(&project);
|
||||||
|
EXPECT_EQ(added, 1);
|
||||||
|
EXPECT_EQ(last_added, math);
|
||||||
|
EXPECT_TRUE(project.nodes().contains(math));
|
||||||
|
EXPECT_EQ(project.nodes().size(), 2);
|
||||||
|
|
||||||
|
delete math;
|
||||||
|
EXPECT_EQ(removed, 1);
|
||||||
|
EXPECT_FALSE(project.nodes().contains(math));
|
||||||
|
EXPECT_EQ(project.nodes().size(), 1);
|
||||||
|
|
||||||
|
// Clear() destructively removes every node from the graph
|
||||||
|
auto *a = new olive::MathNode();
|
||||||
|
a->setParent(&project);
|
||||||
|
auto *b = new olive::MathNode();
|
||||||
|
b->setParent(&project);
|
||||||
|
ASSERT_EQ(project.nodes().size(), 3);
|
||||||
|
project.Clear();
|
||||||
|
EXPECT_TRUE(project.nodes().isEmpty());
|
||||||
|
// One removal from the earlier delete, plus root + 2 nodes from Clear()
|
||||||
|
EXPECT_EQ(removed, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, ContextCounting)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project project;
|
||||||
|
project.Initialize();
|
||||||
|
|
||||||
|
auto *node = new olive::MathNode();
|
||||||
|
node->setParent(&project);
|
||||||
|
auto *folder = new olive::Folder();
|
||||||
|
folder->setParent(&project);
|
||||||
|
|
||||||
|
EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node), 0);
|
||||||
|
|
||||||
|
EXPECT_TRUE(folder->SetNodePositionInContext(
|
||||||
|
node, olive::Node::Position(QPointF(1.0, 2.0), true)));
|
||||||
|
EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node), 1);
|
||||||
|
EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, true), 1);
|
||||||
|
|
||||||
|
// except_itself only excludes the queried node when it acts as its own
|
||||||
|
// context
|
||||||
|
node->SetNodePositionInContext(node,
|
||||||
|
olive::Node::Position(QPointF(), true));
|
||||||
|
EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, false), 2);
|
||||||
|
EXPECT_EQ(project.GetNumberOfContextsNodeIsIn(node, true), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, CopySettingsCopiesEntireMap)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::Project from;
|
||||||
|
olive::Project to;
|
||||||
|
|
||||||
|
from.SetSetting(QStringLiteral("alpha"), QStringLiteral("1"));
|
||||||
|
from.SetCustomCachePath(QStringLiteral("/tmp/x"));
|
||||||
|
|
||||||
|
EXPECT_TRUE(to.GetSetting(QStringLiteral("alpha")).isEmpty());
|
||||||
|
|
||||||
|
olive::Project::CopySettings(&from, &to);
|
||||||
|
EXPECT_EQ(to.GetSetting(QStringLiteral("alpha")), QStringLiteral("1"));
|
||||||
|
EXPECT_EQ(to.GetCustomCachePath(), QStringLiteral("/tmp/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, SaveLoadRoundTripPreservesUuidSettingsAndRoot)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::NodeFactory::Initialize();
|
||||||
|
|
||||||
|
QTemporaryDir dir;
|
||||||
|
ASSERT_TRUE(dir.isValid());
|
||||||
|
const QString path =
|
||||||
|
QDir(dir.path()).filePath(QStringLiteral("roundtrip.ove"));
|
||||||
|
|
||||||
|
const QUuid fixed_uuid(
|
||||||
|
QStringLiteral("{12345678-1234-1234-1234-1234567890ab}"));
|
||||||
|
|
||||||
|
{
|
||||||
|
olive::Project project;
|
||||||
|
project.Initialize();
|
||||||
|
project.SetUuid(fixed_uuid);
|
||||||
|
project.SetSetting(QStringLiteral("customkey"),
|
||||||
|
QStringLiteral("customvalue"));
|
||||||
|
|
||||||
|
QFile file(path);
|
||||||
|
ASSERT_TRUE(file.open(QFile::WriteOnly));
|
||||||
|
QXmlStreamWriter writer(&file);
|
||||||
|
writer.writeStartDocument();
|
||||||
|
writer.writeStartElement(QStringLiteral("project"));
|
||||||
|
project.Save(&writer);
|
||||||
|
writer.writeEndElement();
|
||||||
|
writer.writeEndDocument();
|
||||||
|
file.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// The project being loaded into must not be Initialize()d: Load()
|
||||||
|
// re-resolves the root folder from the saved settings and asserts it
|
||||||
|
// does not exist yet
|
||||||
|
olive::Project loaded;
|
||||||
|
QFile in(path);
|
||||||
|
ASSERT_TRUE(in.open(QFile::ReadOnly));
|
||||||
|
QXmlStreamReader reader(&in);
|
||||||
|
ASSERT_TRUE(reader.readNextStartElement());
|
||||||
|
ASSERT_EQ(reader.name(), QStringLiteral("project"));
|
||||||
|
olive::SerializedData data = loaded.Load(&reader);
|
||||||
|
in.close();
|
||||||
|
|
||||||
|
EXPECT_EQ(loaded.GetUuid(), fixed_uuid);
|
||||||
|
EXPECT_EQ(loaded.GetSetting(QStringLiteral("customkey")),
|
||||||
|
QStringLiteral("customvalue"));
|
||||||
|
|
||||||
|
// The root folder was re-created as a new instance and the root setting
|
||||||
|
// now points at it
|
||||||
|
ASSERT_NE(loaded.root(), nullptr);
|
||||||
|
EXPECT_TRUE(loaded.nodes().contains(loaded.root()));
|
||||||
|
EXPECT_EQ(loaded.GetSetting(olive::Project::kRootKey),
|
||||||
|
QString::number(reinterpret_cast<quintptr>(loaded.root())));
|
||||||
|
EXPECT_FALSE(data.node_ptrs.isEmpty());
|
||||||
|
|
||||||
|
olive::NodeFactory::Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(Project, LoadSkipsUnknownAndEmptyNodeIds)
|
||||||
|
{
|
||||||
|
olive::ColorManager::SetUpDefaultConfig();
|
||||||
|
EnsureAppSingletons();
|
||||||
|
olive::NodeFactory::Initialize();
|
||||||
|
|
||||||
|
const QString xml = QStringLiteral(
|
||||||
|
"<project>"
|
||||||
|
"<nodes>"
|
||||||
|
"<node id=\"org.example.doesnotexist\"></node>"
|
||||||
|
"<node></node>"
|
||||||
|
"<node id=\"org.olivevideoeditor.Olive.math\"></node>"
|
||||||
|
"</nodes>"
|
||||||
|
"</project>");
|
||||||
|
|
||||||
|
olive::Project project;
|
||||||
|
QXmlStreamReader reader(xml);
|
||||||
|
ASSERT_TRUE(reader.readNextStartElement());
|
||||||
|
ASSERT_EQ(reader.name(), QStringLiteral("project"));
|
||||||
|
project.Load(&reader);
|
||||||
|
|
||||||
|
// Only the node with a known, non-empty id made it into the graph
|
||||||
|
ASSERT_EQ(project.nodes().size(), 1);
|
||||||
|
EXPECT_EQ(project.nodes().first()->id(),
|
||||||
|
QStringLiteral("org.olivevideoeditor.Olive.math"));
|
||||||
|
|
||||||
|
olive::NodeFactory::Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, CreateFromFactoryIndexReturnsNonNullUniqueIds)
|
||||||
|
{
|
||||||
|
QSet<QString> ids;
|
||||||
|
for (int i = 0; i < int(olive::NodeFactory::kInternalNodeCount); ++i) {
|
||||||
|
const olive::NodeFactory::InternalID factory_id =
|
||||||
|
static_cast<olive::NodeFactory::InternalID>(i);
|
||||||
|
olive::Node *node =
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(factory_id);
|
||||||
|
ASSERT_NE(node, nullptr) << "factory index" << i << "returned null";
|
||||||
|
EXPECT_FALSE(node->id().isEmpty());
|
||||||
|
EXPECT_FALSE(ids.contains(node->id()))
|
||||||
|
<< "duplicate id" << node->id().toStdString();
|
||||||
|
ids.insert(node->id());
|
||||||
|
delete node;
|
||||||
|
}
|
||||||
|
EXPECT_EQ(ids.size(), int(olive::NodeFactory::kInternalNodeCount));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, CreateFromFactoryIndexReturnsExpectedTypes)
|
||||||
|
{
|
||||||
|
std::unique_ptr<olive::Node> footage(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kProjectFootage));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::Footage *>(footage.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> sequence(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kProjectSequence));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::Sequence *>(sequence.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> folder(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kProjectFolder));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::Folder *>(folder.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> track(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kTrackOutput));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::Track *>(track.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> viewer(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kViewerOutput));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::ViewerOutput *>(viewer.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> solid(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kSolidGenerator));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::SolidGenerator *>(solid.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> math(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(olive::NodeFactory::kMath));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::MathNode *>(math.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> text(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kTextGeneratorV3));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::TextGeneratorV3 *>(text.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> clip(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kClipBlock));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::ClipBlock *>(clip.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> gap(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kGapBlock));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::GapBlock *>(gap.get()), nullptr);
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> group(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kGroupNode));
|
||||||
|
EXPECT_NE(dynamic_cast<olive::NodeGroup *>(group.get()), nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, CreateFromFactoryIndexCountReturnsNull)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
olive::NodeFactory::kInternalNodeCount),
|
||||||
|
nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, LibraryRoundTripAfterInitialize)
|
||||||
|
{
|
||||||
|
olive::NodeFactory::Initialize();
|
||||||
|
|
||||||
|
for (int i = 0; i < int(olive::NodeFactory::kInternalNodeCount); ++i) {
|
||||||
|
std::unique_ptr<olive::Node> probe(
|
||||||
|
olive::NodeFactory::CreateFromFactoryIndex(
|
||||||
|
static_cast<olive::NodeFactory::InternalID>(i)));
|
||||||
|
ASSERT_NE(probe, nullptr);
|
||||||
|
|
||||||
|
// Every internal type must be retrievable from the library by id
|
||||||
|
EXPECT_EQ(olive::NodeFactory::GetNameFromID(probe->id()),
|
||||||
|
probe->Name())
|
||||||
|
<< probe->id().toStdString();
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Node> copy(
|
||||||
|
olive::NodeFactory::CreateFromID(probe->id()));
|
||||||
|
ASSERT_NE(copy, nullptr) << probe->id().toStdString();
|
||||||
|
EXPECT_EQ(copy->id(), probe->id());
|
||||||
|
EXPECT_NE(copy.get(), probe.get());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown and empty ids fail gracefully
|
||||||
|
EXPECT_EQ(olive::NodeFactory::CreateFromID(
|
||||||
|
QStringLiteral("org.example.nonexistent")),
|
||||||
|
nullptr);
|
||||||
|
EXPECT_EQ(olive::NodeFactory::CreateFromID(QString()), nullptr);
|
||||||
|
EXPECT_TRUE(olive::NodeFactory::GetNameFromID(
|
||||||
|
QStringLiteral("org.example.nonexistent"))
|
||||||
|
.isEmpty());
|
||||||
|
EXPECT_TRUE(olive::NodeFactory::GetNameFromID(QString()).isEmpty());
|
||||||
|
|
||||||
|
olive::NodeFactory::Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, CreateMenuWithNoneItem)
|
||||||
|
{
|
||||||
|
olive::NodeFactory::Initialize();
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(
|
||||||
|
olive::NodeFactory::CreateMenu(nullptr, true));
|
||||||
|
ASSERT_NE(menu, nullptr);
|
||||||
|
ASSERT_FALSE(menu->actions().isEmpty());
|
||||||
|
|
||||||
|
// The "None" item is inserted at the very top and maps to nothing
|
||||||
|
QAction *none_item = menu->actions().first();
|
||||||
|
EXPECT_EQ(none_item->data().toInt(), -1);
|
||||||
|
EXPECT_EQ(olive::NodeFactory::CreateFromMenuAction(none_item), nullptr);
|
||||||
|
EXPECT_TRUE(olive::NodeFactory::GetIDFromMenuAction(none_item).isEmpty());
|
||||||
|
|
||||||
|
// Leaf actions carry a library index that maps back to node ids
|
||||||
|
QList<QAction *> leaves;
|
||||||
|
CollectLeafActions(menu.get(), &leaves);
|
||||||
|
ASSERT_GT(leaves.size(), 1);
|
||||||
|
|
||||||
|
int created = 0;
|
||||||
|
for (QAction *leaf : leaves) {
|
||||||
|
if (leaf->data().toInt() < 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const QString id = olive::NodeFactory::GetIDFromMenuAction(leaf);
|
||||||
|
EXPECT_FALSE(id.isEmpty());
|
||||||
|
std::unique_ptr<olive::Node> node(
|
||||||
|
olive::NodeFactory::CreateFromMenuAction(leaf));
|
||||||
|
ASSERT_NE(node, nullptr);
|
||||||
|
EXPECT_EQ(node->id(), id);
|
||||||
|
++created;
|
||||||
|
}
|
||||||
|
EXPECT_GT(created, 0);
|
||||||
|
|
||||||
|
olive::NodeFactory::Destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(NodeFactory, CreateMenuRestrictedToCategory)
|
||||||
|
{
|
||||||
|
olive::NodeFactory::Initialize();
|
||||||
|
|
||||||
|
std::unique_ptr<olive::Menu> menu(olive::NodeFactory::CreateMenu(
|
||||||
|
nullptr, false, olive::Node::kCategoryMath));
|
||||||
|
ASSERT_NE(menu, nullptr);
|
||||||
|
|
||||||
|
QList<QAction *> leaves;
|
||||||
|
CollectLeafActions(menu.get(), &leaves);
|
||||||
|
ASSERT_FALSE(leaves.isEmpty());
|
||||||
|
|
||||||
|
for (QAction *leaf : leaves) {
|
||||||
|
std::unique_ptr<olive::Node> node(
|
||||||
|
olive::NodeFactory::CreateFromMenuAction(leaf));
|
||||||
|
ASSERT_NE(node, nullptr);
|
||||||
|
EXPECT_TRUE(node->Category().contains(olive::Node::kCategoryMath))
|
||||||
|
<< node->id().toStdString();
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::NodeFactory::Destroy();
|
||||||
|
}
|
||||||
@@ -0,0 +1,882 @@
|
|||||||
|
/*
|
||||||
|
* Oak Video Editor - Render Worker Pool & IPC Coverage Tests
|
||||||
|
* Copyright (C) 2026 Oak Team
|
||||||
|
*
|
||||||
|
* CPU-only, headless coverage for the render worker IPC stack that
|
||||||
|
* render_ipc_test.cpp and render_worker_footage_test.cpp do not reach:
|
||||||
|
* - SharedMemoryRegion (named shared memory create/attach/lifetime)
|
||||||
|
* - FrameSlotPool (layout math, invalid attach, metadata fields, FIFO order)
|
||||||
|
* - NDJSON messages (color transform, legacy input_slot fallback, defaults,
|
||||||
|
* blank/non-object lines, closed-device writes)
|
||||||
|
* - RenderWorkerPool (early validation paths that need no worker process)
|
||||||
|
* - DecoderCache (DecoderPair defaults and insert/value round trip)
|
||||||
|
*
|
||||||
|
* No worker processes, GPU, audio devices, or network access are required; the
|
||||||
|
* shared-memory tests use real OS segments with per-run unique keys.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <memory>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <QBuffer>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QSet>
|
||||||
|
|
||||||
|
#include <olive/core/core.h>
|
||||||
|
|
||||||
|
#include "codec/decoder.h"
|
||||||
|
#include "node/output/track/track.h"
|
||||||
|
#include "render/ipc/frameslotpool.h"
|
||||||
|
#include "render/ipc/ipcmessage.h"
|
||||||
|
#include "render/ipc/sharedmemoryregion.h"
|
||||||
|
#include "render/rendercache.h"
|
||||||
|
#include "render/renderworkerpool.h"
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
// Unique-per-run segment key so stale POSIX segments left by earlier runs can
|
||||||
|
// never collide with a test (MakeKey() bakes the pid into the key).
|
||||||
|
QString TestShmKey(const char *tag)
|
||||||
|
{
|
||||||
|
return olive::ipc::SharedMemoryRegion::MakeKey(
|
||||||
|
QCoreApplication::applicationPid(), 99) +
|
||||||
|
QStringLiteral("-") + QLatin1String(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
olive::RenderManager::RenderVideoParams
|
||||||
|
MakeVideoParams(olive::Node *node, const olive::VideoParams &video_params)
|
||||||
|
{
|
||||||
|
return olive::RenderManager::RenderVideoParams(
|
||||||
|
node, video_params, olive::core::AudioParams(),
|
||||||
|
olive::core::rational(0), nullptr, olive::RenderMode::kOnline);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SharedMemoryRegion
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, MakeKeyFormat)
|
||||||
|
{
|
||||||
|
EXPECT_EQ(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3),
|
||||||
|
QStringLiteral("olive-rw-12345-3"));
|
||||||
|
|
||||||
|
EXPECT_NE(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3),
|
||||||
|
olive::ipc::SharedMemoryRegion::MakeKey(12345, 4));
|
||||||
|
EXPECT_NE(olive::ipc::SharedMemoryRegion::MakeKey(12345, 3),
|
||||||
|
olive::ipc::SharedMemoryRegion::MakeKey(12346, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, CreateProvidesZeroedWritableMemory)
|
||||||
|
{
|
||||||
|
const QString key = TestShmKey("zeroed");
|
||||||
|
olive::ipc::SharedMemoryRegion region;
|
||||||
|
ASSERT_TRUE(region.Open(key, 4096, olive::ipc::SharedMemoryRegion::kCreate))
|
||||||
|
<< region.error().toStdString();
|
||||||
|
|
||||||
|
EXPECT_TRUE(region.IsValid());
|
||||||
|
EXPECT_EQ(region.size(), size_t(4096));
|
||||||
|
EXPECT_EQ(region.key(), key);
|
||||||
|
ASSERT_NE(region.data(), nullptr);
|
||||||
|
|
||||||
|
// Freshly created segments are zero-filled.
|
||||||
|
const auto *bytes = static_cast<const uint8_t *>(region.data());
|
||||||
|
for (size_t i = 0; i < region.size(); i++) {
|
||||||
|
ASSERT_EQ(bytes[i], 0) << "byte " << i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mapping is readable and writable.
|
||||||
|
auto *writable = static_cast<uint8_t *>(region.data());
|
||||||
|
for (size_t i = 0; i < region.size(); i++) {
|
||||||
|
writable[i] = uint8_t(i * 31 + 7);
|
||||||
|
}
|
||||||
|
EXPECT_EQ(writable[0], 7);
|
||||||
|
EXPECT_EQ(writable[4095], uint8_t(4095 * 31 + 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, AttachToMissingKeyFails)
|
||||||
|
{
|
||||||
|
olive::ipc::SharedMemoryRegion region;
|
||||||
|
EXPECT_FALSE(region.Open(TestShmKey("missing"), 4096,
|
||||||
|
olive::ipc::SharedMemoryRegion::kAttach));
|
||||||
|
EXPECT_FALSE(region.IsValid());
|
||||||
|
EXPECT_EQ(region.data(), nullptr);
|
||||||
|
EXPECT_FALSE(region.error().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, CreateAttachRoundTrip)
|
||||||
|
{
|
||||||
|
const QString key = TestShmKey("roundtrip");
|
||||||
|
|
||||||
|
olive::ipc::SharedMemoryRegion owner;
|
||||||
|
ASSERT_TRUE(owner.Open(key, 8192, olive::ipc::SharedMemoryRegion::kCreate))
|
||||||
|
<< owner.error().toStdString();
|
||||||
|
|
||||||
|
olive::ipc::SharedMemoryRegion peer;
|
||||||
|
ASSERT_TRUE(peer.Open(key, 8192, olive::ipc::SharedMemoryRegion::kAttach))
|
||||||
|
<< peer.error().toStdString();
|
||||||
|
EXPECT_TRUE(peer.IsValid());
|
||||||
|
EXPECT_EQ(peer.size(), size_t(8192));
|
||||||
|
EXPECT_EQ(peer.key(), key);
|
||||||
|
|
||||||
|
// Writes through the owner mapping are visible through the peer mapping.
|
||||||
|
auto *owner_bytes = static_cast<uint8_t *>(owner.data());
|
||||||
|
const auto *peer_bytes = static_cast<const uint8_t *>(peer.data());
|
||||||
|
for (size_t i = 0; i < 8192; i += 257) {
|
||||||
|
owner_bytes[i] = uint8_t(i ^ 0x5A);
|
||||||
|
}
|
||||||
|
for (size_t i = 0; i < 8192; i += 257) {
|
||||||
|
EXPECT_EQ(peer_bytes[i], uint8_t(i ^ 0x5A)) << "offset " << i;
|
||||||
|
}
|
||||||
|
|
||||||
|
// And vice versa: the peer maps the same segment read/write.
|
||||||
|
auto *peer_writable = static_cast<uint8_t *>(peer.data());
|
||||||
|
peer_writable[123] = 0xA5;
|
||||||
|
EXPECT_EQ(owner_bytes[123], 0xA5);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, ZeroSizeCreateFails)
|
||||||
|
{
|
||||||
|
olive::ipc::SharedMemoryRegion region;
|
||||||
|
// A zero-length mapping is rejected (EINVAL from mmap on POSIX, invalid
|
||||||
|
// size for CreateFileMapping on Windows).
|
||||||
|
EXPECT_FALSE(region.Open(TestShmKey("zerosize"), 0,
|
||||||
|
olive::ipc::SharedMemoryRegion::kCreate));
|
||||||
|
EXPECT_FALSE(region.IsValid());
|
||||||
|
EXPECT_FALSE(region.error().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, CloseInvalidatesThenReopenWorks)
|
||||||
|
{
|
||||||
|
olive::ipc::SharedMemoryRegion region;
|
||||||
|
ASSERT_TRUE(region.Open(TestShmKey("close1"), 4096,
|
||||||
|
olive::ipc::SharedMemoryRegion::kCreate));
|
||||||
|
|
||||||
|
region.Close();
|
||||||
|
EXPECT_FALSE(region.IsValid());
|
||||||
|
EXPECT_EQ(region.data(), nullptr);
|
||||||
|
EXPECT_EQ(region.size(), size_t(0));
|
||||||
|
|
||||||
|
// Close is idempotent.
|
||||||
|
region.Close();
|
||||||
|
EXPECT_FALSE(region.IsValid());
|
||||||
|
|
||||||
|
// The same object can be reused for a new segment (Open() closes first).
|
||||||
|
ASSERT_TRUE(region.Open(TestShmKey("close2"), 2048,
|
||||||
|
olive::ipc::SharedMemoryRegion::kCreate));
|
||||||
|
EXPECT_TRUE(region.IsValid());
|
||||||
|
EXPECT_EQ(region.size(), size_t(2048));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(SharedMemoryRegion, OwnerDestructionUnlinksSegment)
|
||||||
|
{
|
||||||
|
const QString key = TestShmKey("unlink");
|
||||||
|
{
|
||||||
|
olive::ipc::SharedMemoryRegion owner;
|
||||||
|
ASSERT_TRUE(owner.Open(key, 4096,
|
||||||
|
olive::ipc::SharedMemoryRegion::kCreate));
|
||||||
|
|
||||||
|
// While the owner lives, attaching works.
|
||||||
|
olive::ipc::SharedMemoryRegion peer;
|
||||||
|
ASSERT_TRUE(
|
||||||
|
peer.Open(key, 4096, olive::ipc::SharedMemoryRegion::kAttach));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Once the owner is destroyed the name is unlinked; new attaches fail.
|
||||||
|
olive::ipc::SharedMemoryRegion late;
|
||||||
|
EXPECT_FALSE(late.Open(key, 4096, olive::ipc::SharedMemoryRegion::kAttach));
|
||||||
|
EXPECT_FALSE(late.IsValid());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FrameSlotPool
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, AttachRejectsBadMagic)
|
||||||
|
{
|
||||||
|
// A region that was never initialized by Create() has no valid magic number.
|
||||||
|
std::vector<uint8_t> mem(olive::ipc::FrameSlotPool::BytesNeeded(2, 64), 0xAB);
|
||||||
|
olive::ipc::FrameSlotPool pool = olive::ipc::FrameSlotPool::Attach(mem.data());
|
||||||
|
|
||||||
|
EXPECT_FALSE(pool.IsValid());
|
||||||
|
EXPECT_EQ(pool.slot_count(), 0u);
|
||||||
|
EXPECT_EQ(pool.slot_data_bytes(), size_t(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, DefaultConstructedIsInvalid)
|
||||||
|
{
|
||||||
|
olive::ipc::FrameSlotPool pool;
|
||||||
|
EXPECT_FALSE(pool.IsValid());
|
||||||
|
EXPECT_EQ(pool.slot_count(), 0u);
|
||||||
|
EXPECT_EQ(pool.slot_data_bytes(), size_t(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, BytesNeededReflectsGeometry)
|
||||||
|
{
|
||||||
|
// The total is a sum of 64-byte-aligned sub-regions, so it stays 64-aligned.
|
||||||
|
EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(1, 64) % 64, 0u);
|
||||||
|
EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(3, 1000) % 64, 0u);
|
||||||
|
|
||||||
|
// More slots and bigger slots both need strictly more memory...
|
||||||
|
EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(1, 64),
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(2, 64));
|
||||||
|
EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(2, 64),
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(3, 64));
|
||||||
|
EXPECT_LT(olive::ipc::FrameSlotPool::BytesNeeded(2, 64),
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(2, 128));
|
||||||
|
|
||||||
|
// ...but sizes inside the same 64-byte alignment bucket collapse together.
|
||||||
|
EXPECT_EQ(olive::ipc::FrameSlotPool::BytesNeeded(2, 65),
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(2, 128));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, SlotDataBlocksAreAlignedAndDistinct)
|
||||||
|
{
|
||||||
|
constexpr uint32_t kSlots = 2;
|
||||||
|
constexpr size_t kSlotBytes = 100; // deliberately not 64-aligned
|
||||||
|
|
||||||
|
std::vector<uint8_t> mem(
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes));
|
||||||
|
olive::ipc::FrameSlotPool pool =
|
||||||
|
olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, kSlotBytes);
|
||||||
|
ASSERT_TRUE(pool.IsValid());
|
||||||
|
|
||||||
|
auto *first = static_cast<uint8_t *>(pool.SlotData(0));
|
||||||
|
auto *second = static_cast<uint8_t *>(pool.SlotData(1));
|
||||||
|
|
||||||
|
// Slot data blocks are padded out to 64-byte boundaries within the region.
|
||||||
|
EXPECT_EQ(second - first, ptrdiff_t(128));
|
||||||
|
|
||||||
|
// A full-size write to one slot never spills into the next.
|
||||||
|
std::memset(first, 0x11, kSlotBytes);
|
||||||
|
std::memset(second, 0x22, kSlotBytes);
|
||||||
|
EXPECT_EQ(first[kSlotBytes - 1], 0x11);
|
||||||
|
EXPECT_EQ(second[0], 0x22);
|
||||||
|
|
||||||
|
// The const overload maps the same addresses.
|
||||||
|
const olive::ipc::FrameSlotPool &const_pool = pool;
|
||||||
|
EXPECT_EQ(static_cast<const uint8_t *>(const_pool.SlotData(0)), first);
|
||||||
|
EXPECT_EQ(static_cast<const uint8_t *>(const_pool.SlotData(1)), second);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, MetadataFieldsRoundTrip)
|
||||||
|
{
|
||||||
|
constexpr uint32_t kSlots = 2;
|
||||||
|
constexpr size_t kSlotBytes = 64;
|
||||||
|
|
||||||
|
std::vector<uint8_t> mem(
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes));
|
||||||
|
olive::ipc::FrameSlotPool filler =
|
||||||
|
olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, kSlotBytes);
|
||||||
|
olive::ipc::FrameSlotPool drainer =
|
||||||
|
olive::ipc::FrameSlotPool::Attach(mem.data());
|
||||||
|
|
||||||
|
uint32_t idx = 0;
|
||||||
|
ASSERT_TRUE(filler.Acquire(&idx));
|
||||||
|
|
||||||
|
// Freshly created pools zero the metadata array.
|
||||||
|
const olive::ipc::FrameSlotPool &const_drainer = drainer;
|
||||||
|
const olive::ipc::FrameSlotMeta *blank = const_drainer.Meta(idx);
|
||||||
|
EXPECT_EQ(blank->id, 0);
|
||||||
|
EXPECT_EQ(blank->time_num, 0);
|
||||||
|
EXPECT_EQ(blank->time_den, 0);
|
||||||
|
EXPECT_EQ(blank->width, 0);
|
||||||
|
EXPECT_EQ(blank->colorspace[0], '\0');
|
||||||
|
|
||||||
|
// Every field the producer writes survives the hand-off.
|
||||||
|
olive::ipc::FrameSlotMeta *meta = filler.Meta(idx);
|
||||||
|
meta->id = -99;
|
||||||
|
meta->time_num = 1001;
|
||||||
|
meta->time_den = 30000;
|
||||||
|
meta->width = 3840;
|
||||||
|
meta->height = 2160;
|
||||||
|
meta->format = int(olive::core::PixelFormat::F32);
|
||||||
|
meta->channel_count = 4;
|
||||||
|
meta->linesize = 3840 * 4 * 4;
|
||||||
|
meta->data_size = int32_t(kSlotBytes);
|
||||||
|
const char kColorspace[] = "acescg";
|
||||||
|
std::strncpy(meta->colorspace, kColorspace, sizeof(meta->colorspace) - 1);
|
||||||
|
meta->colorspace[sizeof(meta->colorspace) - 1] = '\0';
|
||||||
|
|
||||||
|
ASSERT_TRUE(filler.Publish(idx));
|
||||||
|
|
||||||
|
uint32_t got = 0;
|
||||||
|
ASSERT_TRUE(drainer.Consume(&got));
|
||||||
|
EXPECT_EQ(got, idx);
|
||||||
|
|
||||||
|
const olive::ipc::FrameSlotMeta *out = const_drainer.Meta(got);
|
||||||
|
EXPECT_EQ(out->id, -99);
|
||||||
|
EXPECT_EQ(out->time_num, 1001);
|
||||||
|
EXPECT_EQ(out->time_den, 30000);
|
||||||
|
EXPECT_EQ(out->width, 3840);
|
||||||
|
EXPECT_EQ(out->height, 2160);
|
||||||
|
EXPECT_EQ(out->format, int(olive::core::PixelFormat::F32));
|
||||||
|
EXPECT_EQ(out->channel_count, 4);
|
||||||
|
EXPECT_EQ(out->linesize, 3840 * 4 * 4);
|
||||||
|
EXPECT_EQ(out->data_size, int32_t(kSlotBytes));
|
||||||
|
EXPECT_STREQ(out->colorspace, kColorspace);
|
||||||
|
|
||||||
|
EXPECT_TRUE(drainer.Release(got));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, FreeSlotsAreIssuedInOrder)
|
||||||
|
{
|
||||||
|
constexpr uint32_t kSlots = 4;
|
||||||
|
std::vector<uint8_t> mem(
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(kSlots, 64));
|
||||||
|
olive::ipc::FrameSlotPool pool =
|
||||||
|
olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, 64);
|
||||||
|
|
||||||
|
// Create() seeds the free ring FIFO with every slot index.
|
||||||
|
for (uint32_t expected = 0; expected < kSlots; expected++) {
|
||||||
|
uint32_t idx = 0;
|
||||||
|
ASSERT_TRUE(pool.Acquire(&idx));
|
||||||
|
EXPECT_EQ(idx, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t overflow = 0;
|
||||||
|
EXPECT_FALSE(pool.Acquire(&overflow));
|
||||||
|
|
||||||
|
// Released slots are re-issued in the order they were released.
|
||||||
|
ASSERT_TRUE(pool.Release(2));
|
||||||
|
ASSERT_TRUE(pool.Release(0));
|
||||||
|
uint32_t idx = 0;
|
||||||
|
ASSERT_TRUE(pool.Acquire(&idx));
|
||||||
|
EXPECT_EQ(idx, 2u);
|
||||||
|
ASSERT_TRUE(pool.Acquire(&idx));
|
||||||
|
EXPECT_EQ(idx, 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, ReadyRingDeliversInPublishOrder)
|
||||||
|
{
|
||||||
|
constexpr uint32_t kSlots = 3;
|
||||||
|
std::vector<uint8_t> mem(
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(kSlots, 64));
|
||||||
|
olive::ipc::FrameSlotPool pool =
|
||||||
|
olive::ipc::FrameSlotPool::Create(mem.data(), kSlots, 64);
|
||||||
|
|
||||||
|
uint32_t a = 0, b = 0, c = 0;
|
||||||
|
ASSERT_TRUE(pool.Acquire(&a));
|
||||||
|
ASSERT_TRUE(pool.Acquire(&b));
|
||||||
|
ASSERT_TRUE(pool.Acquire(&c));
|
||||||
|
|
||||||
|
// Publish order, not slot order, determines consume order.
|
||||||
|
ASSERT_TRUE(pool.Publish(c));
|
||||||
|
ASSERT_TRUE(pool.Publish(a));
|
||||||
|
ASSERT_TRUE(pool.Publish(b));
|
||||||
|
|
||||||
|
const uint32_t expected[] = { c, a, b };
|
||||||
|
for (uint32_t want : expected) {
|
||||||
|
uint32_t got = 0;
|
||||||
|
ASSERT_TRUE(pool.Consume(&got));
|
||||||
|
EXPECT_EQ(got, want);
|
||||||
|
ASSERT_TRUE(pool.Release(got));
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32_t empty = 0;
|
||||||
|
EXPECT_FALSE(pool.Consume(&empty));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(FrameSlotPool, CrossMappingHandoff)
|
||||||
|
{
|
||||||
|
constexpr uint32_t kSlots = 2;
|
||||||
|
constexpr size_t kSlotBytes = 128;
|
||||||
|
const QString key = TestShmKey("pool-handoff");
|
||||||
|
|
||||||
|
const size_t bytes =
|
||||||
|
olive::ipc::FrameSlotPool::BytesNeeded(kSlots, kSlotBytes);
|
||||||
|
|
||||||
|
olive::ipc::SharedMemoryRegion owner_region;
|
||||||
|
ASSERT_TRUE(owner_region.Open(key, bytes,
|
||||||
|
olive::ipc::SharedMemoryRegion::kCreate))
|
||||||
|
<< owner_region.error().toStdString();
|
||||||
|
olive::ipc::FrameSlotPool filler = olive::ipc::FrameSlotPool::Create(
|
||||||
|
owner_region.data(), kSlots, kSlotBytes);
|
||||||
|
ASSERT_TRUE(filler.IsValid());
|
||||||
|
|
||||||
|
// The peer maps the same segment separately and attaches to the pool header.
|
||||||
|
olive::ipc::SharedMemoryRegion peer_region;
|
||||||
|
ASSERT_TRUE(peer_region.Open(key, bytes,
|
||||||
|
olive::ipc::SharedMemoryRegion::kAttach))
|
||||||
|
<< peer_region.error().toStdString();
|
||||||
|
olive::ipc::FrameSlotPool drainer =
|
||||||
|
olive::ipc::FrameSlotPool::Attach(peer_region.data());
|
||||||
|
ASSERT_TRUE(drainer.IsValid());
|
||||||
|
EXPECT_EQ(drainer.slot_count(), kSlots);
|
||||||
|
EXPECT_EQ(drainer.slot_data_bytes(), kSlotBytes);
|
||||||
|
|
||||||
|
// Filler side: acquire a slot, stamp it, publish it.
|
||||||
|
uint32_t idx = 0;
|
||||||
|
ASSERT_TRUE(filler.Acquire(&idx));
|
||||||
|
auto *data = static_cast<uint8_t *>(filler.SlotData(idx));
|
||||||
|
for (size_t i = 0; i < kSlotBytes; i++) {
|
||||||
|
data[i] = uint8_t(0xC3 ^ i);
|
||||||
|
}
|
||||||
|
filler.Meta(idx)->id = 777;
|
||||||
|
ASSERT_TRUE(filler.Publish(idx));
|
||||||
|
|
||||||
|
// Drainer side (through the second mapping): same slot, meta and pixels.
|
||||||
|
uint32_t got = 0;
|
||||||
|
ASSERT_TRUE(drainer.Consume(&got));
|
||||||
|
EXPECT_EQ(got, idx);
|
||||||
|
EXPECT_EQ(drainer.Meta(got)->id, 777);
|
||||||
|
const auto *peer_data =
|
||||||
|
static_cast<const uint8_t *>(drainer.SlotData(got));
|
||||||
|
for (size_t i = 0; i < kSlotBytes; i++) {
|
||||||
|
ASSERT_EQ(peer_data[i], uint8_t(0xC3 ^ i)) << "byte " << i;
|
||||||
|
}
|
||||||
|
ASSERT_TRUE(drainer.Release(got));
|
||||||
|
|
||||||
|
// The release crosses back to the owner's mapping. The free ring is FIFO:
|
||||||
|
// the next fresh slot comes first, then the released slot cycles back.
|
||||||
|
uint32_t reacquired = 0;
|
||||||
|
ASSERT_TRUE(filler.Acquire(&reacquired));
|
||||||
|
EXPECT_EQ(reacquired, 1u);
|
||||||
|
ASSERT_TRUE(filler.Acquire(&reacquired));
|
||||||
|
EXPECT_EQ(reacquired, got);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NDJSON control messages (beyond render_ipc_test.cpp)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST(IpcMessage, LoadGraphRoundTrip)
|
||||||
|
{
|
||||||
|
olive::ipc::LoadGraphMsg msg;
|
||||||
|
msg.path = QStringLiteral("/tmp/oak-render-graph-abc123.ove");
|
||||||
|
|
||||||
|
const QJsonObject obj = msg.ToJson();
|
||||||
|
EXPECT_EQ(obj.value(QStringLiteral("type")).toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kLoadGraph));
|
||||||
|
|
||||||
|
olive::ipc::LoadGraphMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::LoadGraphMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_EQ(back.path, msg.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, RenderFrameColorTransformRoundTrip)
|
||||||
|
{
|
||||||
|
olive::ipc::RenderFrameMsg msg;
|
||||||
|
msg.ticket_id = 123;
|
||||||
|
msg.node_uuid = QStringLiteral("{11111111-2222-3333-4444-555555555555}");
|
||||||
|
msg.time_num = 1001;
|
||||||
|
msg.time_den = 24000;
|
||||||
|
msg.width = 1920;
|
||||||
|
msg.height = 1080;
|
||||||
|
msg.format = int(olive::core::PixelFormat::F32);
|
||||||
|
msg.channel_count = 4;
|
||||||
|
msg.mode = 1;
|
||||||
|
msg.input_slots = { 0, 2, 5 };
|
||||||
|
msg.has_color_transform = true;
|
||||||
|
msg.color_is_display = true;
|
||||||
|
msg.color_output = QStringLiteral("sRGB - Display");
|
||||||
|
msg.color_view = QStringLiteral("ACES 1.0 SDR-video");
|
||||||
|
msg.color_look = QStringLiteral("None");
|
||||||
|
|
||||||
|
const QJsonObject obj = msg.ToJson();
|
||||||
|
EXPECT_EQ(obj.value(QStringLiteral("type")).toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kRenderFrame));
|
||||||
|
EXPECT_TRUE(obj.value(QStringLiteral("has_color_transform")).toBool());
|
||||||
|
|
||||||
|
olive::ipc::RenderFrameMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_EQ(back.ticket_id, msg.ticket_id);
|
||||||
|
EXPECT_EQ(back.node_uuid, msg.node_uuid);
|
||||||
|
EXPECT_EQ(back.time_num, msg.time_num);
|
||||||
|
EXPECT_EQ(back.time_den, msg.time_den);
|
||||||
|
EXPECT_EQ(back.width, msg.width);
|
||||||
|
EXPECT_EQ(back.height, msg.height);
|
||||||
|
EXPECT_EQ(back.format, msg.format);
|
||||||
|
EXPECT_EQ(back.channel_count, msg.channel_count);
|
||||||
|
EXPECT_EQ(back.mode, msg.mode);
|
||||||
|
EXPECT_EQ(back.input_slots, msg.input_slots);
|
||||||
|
EXPECT_TRUE(back.has_color_transform);
|
||||||
|
EXPECT_TRUE(back.color_is_display);
|
||||||
|
EXPECT_EQ(back.color_output, msg.color_output);
|
||||||
|
EXPECT_EQ(back.color_view, msg.color_view);
|
||||||
|
EXPECT_EQ(back.color_look, msg.color_look);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, RenderFrameOmitsColorTransformWhenUnset)
|
||||||
|
{
|
||||||
|
olive::ipc::RenderFrameMsg msg; // has_color_transform defaults to false
|
||||||
|
const QJsonObject obj = msg.ToJson();
|
||||||
|
|
||||||
|
EXPECT_FALSE(obj.contains(QStringLiteral("has_color_transform")));
|
||||||
|
EXPECT_FALSE(obj.contains(QStringLiteral("color_output")));
|
||||||
|
EXPECT_FALSE(obj.contains(QStringLiteral("color_view")));
|
||||||
|
EXPECT_FALSE(obj.contains(QStringLiteral("color_look")));
|
||||||
|
|
||||||
|
olive::ipc::RenderFrameMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_FALSE(back.has_color_transform);
|
||||||
|
EXPECT_FALSE(back.color_is_display);
|
||||||
|
EXPECT_TRUE(back.color_output.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, RenderFrameLegacyInputSlotFallback)
|
||||||
|
{
|
||||||
|
// Older peers only send the scalar "input_slot"; FromJson folds it into the
|
||||||
|
// input_slots array when the array is absent.
|
||||||
|
QJsonObject obj;
|
||||||
|
obj[QStringLiteral("type")] =
|
||||||
|
QLatin1String(olive::ipc::msgtype::kRenderFrame);
|
||||||
|
obj[QStringLiteral("ticket")] = 5.0;
|
||||||
|
obj[QStringLiteral("input_slot")] = 3;
|
||||||
|
|
||||||
|
olive::ipc::RenderFrameMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_EQ(back.input_slot, 3);
|
||||||
|
ASSERT_EQ(back.input_slots.size(), 1);
|
||||||
|
EXPECT_EQ(back.input_slots.first(), 3);
|
||||||
|
|
||||||
|
// When the array is present it wins and the scalar is not duplicated.
|
||||||
|
obj[QStringLiteral("input_slots")] = QJsonArray{ 7, 8 };
|
||||||
|
olive::ipc::RenderFrameMsg back2;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back2));
|
||||||
|
ASSERT_EQ(back2.input_slots.size(), 2);
|
||||||
|
EXPECT_EQ(back2.input_slots.at(0), 7);
|
||||||
|
EXPECT_EQ(back2.input_slots.at(1), 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, RenderFrameDefaultsFromSparseJson)
|
||||||
|
{
|
||||||
|
// A message carrying only the type tag must still parse, with every field
|
||||||
|
// falling back to its documented default.
|
||||||
|
QJsonObject obj;
|
||||||
|
obj[QStringLiteral("type")] =
|
||||||
|
QLatin1String(olive::ipc::msgtype::kRenderFrame);
|
||||||
|
|
||||||
|
olive::ipc::RenderFrameMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_EQ(back.ticket_id, 0);
|
||||||
|
EXPECT_TRUE(back.node_uuid.isEmpty());
|
||||||
|
EXPECT_EQ(back.time_num, 0);
|
||||||
|
EXPECT_EQ(back.time_den, 1);
|
||||||
|
EXPECT_EQ(back.width, 0);
|
||||||
|
EXPECT_EQ(back.height, 0);
|
||||||
|
EXPECT_EQ(back.format, -1);
|
||||||
|
EXPECT_EQ(back.channel_count, 0);
|
||||||
|
EXPECT_EQ(back.mode, 0);
|
||||||
|
EXPECT_EQ(back.input_slot, -1);
|
||||||
|
EXPECT_TRUE(back.input_slots.isEmpty());
|
||||||
|
EXPECT_FALSE(back.has_color_transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, LargeIdentifiersSurviveRoundTrip)
|
||||||
|
{
|
||||||
|
// 64-bit ids travel as JSON doubles, exact up to 2^53; ticket ids are
|
||||||
|
// pointer-derived and slot sizes are byte counts, both well inside that.
|
||||||
|
const qint64 ticket = (qint64(1) << 52) + 12345;
|
||||||
|
const qint64 slot_bytes = qint64(7680) * 4320 * 4 * 4; // 8K RGBA float
|
||||||
|
|
||||||
|
olive::ipc::RenderFrameMsg rf;
|
||||||
|
rf.ticket_id = ticket;
|
||||||
|
rf.time_num = qint64(48000) * 123456789;
|
||||||
|
rf.time_den = qint64(1) << 40;
|
||||||
|
olive::ipc::RenderFrameMsg rf_back;
|
||||||
|
ASSERT_TRUE(olive::ipc::RenderFrameMsg::FromJson(rf.ToJson(), &rf_back));
|
||||||
|
EXPECT_EQ(rf_back.ticket_id, ticket);
|
||||||
|
EXPECT_EQ(rf_back.time_num, rf.time_num);
|
||||||
|
EXPECT_EQ(rf_back.time_den, rf.time_den);
|
||||||
|
|
||||||
|
olive::ipc::FrameReadyMsg fr;
|
||||||
|
fr.ticket_id = ticket;
|
||||||
|
olive::ipc::FrameReadyMsg fr_back;
|
||||||
|
ASSERT_TRUE(olive::ipc::FrameReadyMsg::FromJson(fr.ToJson(), &fr_back));
|
||||||
|
EXPECT_EQ(fr_back.ticket_id, ticket);
|
||||||
|
|
||||||
|
olive::ipc::CancelMsg cancel;
|
||||||
|
cancel.ticket_id = ticket;
|
||||||
|
olive::ipc::CancelMsg cancel_back;
|
||||||
|
ASSERT_TRUE(olive::ipc::CancelMsg::FromJson(cancel.ToJson(), &cancel_back));
|
||||||
|
EXPECT_EQ(cancel_back.ticket_id, ticket);
|
||||||
|
|
||||||
|
olive::ipc::HandshakeMsg hs;
|
||||||
|
hs.slot_data_bytes = slot_bytes;
|
||||||
|
hs.input_slot_data_bytes = slot_bytes / 2;
|
||||||
|
olive::ipc::HandshakeMsg hs_back;
|
||||||
|
ASSERT_TRUE(olive::ipc::HandshakeMsg::FromJson(hs.ToJson(), &hs_back));
|
||||||
|
EXPECT_EQ(hs_back.slot_data_bytes, slot_bytes);
|
||||||
|
EXPECT_EQ(hs_back.input_slot_data_bytes, slot_bytes / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, TypedBuildersRejectMismatchedType)
|
||||||
|
{
|
||||||
|
const QJsonObject hs_obj = olive::ipc::HandshakeMsg().ToJson();
|
||||||
|
const QJsonObject rf_obj = olive::ipc::RenderFrameMsg().ToJson();
|
||||||
|
const QJsonObject fr_obj = olive::ipc::FrameReadyMsg().ToJson();
|
||||||
|
const QJsonObject cancel_obj = olive::ipc::CancelMsg().ToJson();
|
||||||
|
const QJsonObject load_obj = olive::ipc::LoadGraphMsg().ToJson();
|
||||||
|
|
||||||
|
olive::ipc::HandshakeMsg hs_out;
|
||||||
|
EXPECT_FALSE(olive::ipc::HandshakeMsg::FromJson(rf_obj, &hs_out));
|
||||||
|
olive::ipc::RenderFrameMsg rf_out;
|
||||||
|
EXPECT_FALSE(olive::ipc::RenderFrameMsg::FromJson(cancel_obj, &rf_out));
|
||||||
|
olive::ipc::FrameReadyMsg fr_out;
|
||||||
|
EXPECT_FALSE(olive::ipc::FrameReadyMsg::FromJson(load_obj, &fr_out));
|
||||||
|
olive::ipc::CancelMsg cancel_out;
|
||||||
|
EXPECT_FALSE(olive::ipc::CancelMsg::FromJson(fr_obj, &cancel_out));
|
||||||
|
olive::ipc::LoadGraphMsg load_out;
|
||||||
|
EXPECT_FALSE(olive::ipc::LoadGraphMsg::FromJson(hs_obj, &load_out));
|
||||||
|
|
||||||
|
// An object with no "type" at all is rejected by every parser.
|
||||||
|
const QJsonObject empty;
|
||||||
|
EXPECT_FALSE(olive::ipc::HandshakeMsg::FromJson(empty, &hs_out));
|
||||||
|
EXPECT_FALSE(olive::ipc::RenderFrameMsg::FromJson(empty, &rf_out));
|
||||||
|
EXPECT_FALSE(olive::ipc::FrameReadyMsg::FromJson(empty, &fr_out));
|
||||||
|
EXPECT_FALSE(olive::ipc::CancelMsg::FromJson(empty, &cancel_out));
|
||||||
|
EXPECT_FALSE(olive::ipc::LoadGraphMsg::FromJson(empty, &load_out));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, ReadMessageSkipsBlankLines)
|
||||||
|
{
|
||||||
|
olive::ipc::CancelMsg cancel;
|
||||||
|
cancel.ticket_id = 9;
|
||||||
|
const QByteArray line =
|
||||||
|
QJsonDocument(cancel.ToJson()).toJson(QJsonDocument::Compact);
|
||||||
|
|
||||||
|
// A reader loop sees: blank line, whitespace-only line, then a real message.
|
||||||
|
QByteArray reader = QByteArray("\n \n") + line + '\n';
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
bool ok = true;
|
||||||
|
EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // blank
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok)); // whitespace
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
|
||||||
|
ASSERT_TRUE(olive::ipc::ReadMessage(&reader, &obj, &ok));
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
olive::ipc::CancelMsg back;
|
||||||
|
ASSERT_TRUE(olive::ipc::CancelMsg::FromJson(obj, &back));
|
||||||
|
EXPECT_EQ(back.ticket_id, 9);
|
||||||
|
EXPECT_TRUE(reader.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, ReadMessageRejectsNonObjectJson)
|
||||||
|
{
|
||||||
|
// Valid JSON, but an array rather than an object: consumed, flagged not-ok.
|
||||||
|
QByteArray reader = QByteArray("[1,2,3]\n");
|
||||||
|
QJsonObject obj;
|
||||||
|
bool ok = true;
|
||||||
|
EXPECT_FALSE(olive::ipc::ReadMessage(&reader, &obj, &ok));
|
||||||
|
EXPECT_FALSE(ok);
|
||||||
|
EXPECT_TRUE(reader.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, ReadMessageWorksWithoutOkPointer)
|
||||||
|
{
|
||||||
|
olive::ipc::CancelMsg cancel;
|
||||||
|
cancel.ticket_id = 4;
|
||||||
|
QByteArray reader =
|
||||||
|
QJsonDocument(cancel.ToJson()).toJson(QJsonDocument::Compact);
|
||||||
|
reader.append('\n');
|
||||||
|
|
||||||
|
QJsonObject obj;
|
||||||
|
EXPECT_TRUE(olive::ipc::ReadMessage(&reader, &obj)); // ok defaults to nullptr
|
||||||
|
|
||||||
|
QByteArray bad = QByteArray("garbage\n");
|
||||||
|
EXPECT_FALSE(olive::ipc::ReadMessage(&bad, &obj));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, WriteMessageProducesSingleTerminatedLine)
|
||||||
|
{
|
||||||
|
QByteArray storage;
|
||||||
|
QBuffer device(&storage);
|
||||||
|
ASSERT_TRUE(device.open(QIODevice::WriteOnly));
|
||||||
|
|
||||||
|
olive::ipc::HandshakeMsg hs;
|
||||||
|
hs.protocol_version = 1;
|
||||||
|
hs.shm_key = QStringLiteral("olive-rw-1-0");
|
||||||
|
ASSERT_TRUE(olive::ipc::WriteMessage(&device, hs.ToJson()));
|
||||||
|
device.close();
|
||||||
|
|
||||||
|
// NDJSON: exactly one compact line, newline-terminated.
|
||||||
|
EXPECT_TRUE(storage.startsWith('{'));
|
||||||
|
EXPECT_TRUE(storage.endsWith('\n'));
|
||||||
|
EXPECT_EQ(storage.count('\n'), 1);
|
||||||
|
|
||||||
|
// And it parses back to an identical object.
|
||||||
|
QJsonObject obj;
|
||||||
|
bool ok = false;
|
||||||
|
ASSERT_TRUE(olive::ipc::ReadMessage(&storage, &obj, &ok));
|
||||||
|
EXPECT_TRUE(ok);
|
||||||
|
EXPECT_EQ(obj, hs.ToJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, WriteMessageFailsOnClosedDevice)
|
||||||
|
{
|
||||||
|
QByteArray storage;
|
||||||
|
QBuffer device(&storage); // never opened: writes fail
|
||||||
|
|
||||||
|
olive::ipc::CancelMsg cancel;
|
||||||
|
EXPECT_FALSE(olive::ipc::WriteMessage(&device, cancel.ToJson()));
|
||||||
|
EXPECT_TRUE(storage.isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(IpcMessage, MessageTypeConstantsAreDistinct)
|
||||||
|
{
|
||||||
|
const QSet<QString> types = {
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kHandshake),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kLoadGraph),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kRenderFrame),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kFrameReady),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kCancel),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kGraphUpdate),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kShutdown),
|
||||||
|
QString::fromUtf8(olive::ipc::msgtype::kError),
|
||||||
|
};
|
||||||
|
EXPECT_EQ(types.size(), 8);
|
||||||
|
|
||||||
|
// Each builder stamps its own constant into the "type" field.
|
||||||
|
EXPECT_EQ(olive::ipc::HandshakeMsg()
|
||||||
|
.ToJson()
|
||||||
|
.value(QStringLiteral("type"))
|
||||||
|
.toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kHandshake));
|
||||||
|
EXPECT_EQ(olive::ipc::RenderFrameMsg()
|
||||||
|
.ToJson()
|
||||||
|
.value(QStringLiteral("type"))
|
||||||
|
.toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kRenderFrame));
|
||||||
|
EXPECT_EQ(olive::ipc::FrameReadyMsg()
|
||||||
|
.ToJson()
|
||||||
|
.value(QStringLiteral("type"))
|
||||||
|
.toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kFrameReady));
|
||||||
|
EXPECT_EQ(olive::ipc::CancelMsg()
|
||||||
|
.ToJson()
|
||||||
|
.value(QStringLiteral("type"))
|
||||||
|
.toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kCancel));
|
||||||
|
EXPECT_EQ(olive::ipc::LoadGraphMsg()
|
||||||
|
.ToJson()
|
||||||
|
.value(QStringLiteral("type"))
|
||||||
|
.toString(),
|
||||||
|
QLatin1String(olive::ipc::msgtype::kLoadGraph));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RenderWorkerPool (validation paths that never reach a worker process)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, RemoveTicketRejectsNull)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
EXPECT_FALSE(pool.RemoveTicket(nullptr));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, RemoveTicketUnknownTicketReturnsFalse)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
// The pool thread was never started, so the ticket can be neither queued
|
||||||
|
// nor active.
|
||||||
|
const olive::RenderTicketPtr ticket = std::make_shared<olive::RenderTicket>();
|
||||||
|
EXPECT_FALSE(pool.RemoveTicket(ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, ShutdownWithoutStartIsSafeAndIdempotent)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
// Shutdown on a pool whose thread never ran must not block or crash; the
|
||||||
|
// destructor runs it once more when the pool goes out of scope.
|
||||||
|
pool.Shutdown();
|
||||||
|
pool.Shutdown();
|
||||||
|
EXPECT_FALSE(pool.isRunning());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, SubmitFrameRejectsNullNode)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
const olive::RenderTicketPtr ticket = std::make_shared<olive::RenderTicket>();
|
||||||
|
EXPECT_FALSE(pool.SubmitFrame(
|
||||||
|
ticket,
|
||||||
|
MakeVideoParams(nullptr, olive::VideoParams(
|
||||||
|
64, 64, olive::core::PixelFormat::U8, 4))));
|
||||||
|
|
||||||
|
// A rejected submission must leave the ticket untouched.
|
||||||
|
EXPECT_FALSE(ticket->IsRunning());
|
||||||
|
EXPECT_EQ(ticket->GetFinishCount(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, SubmitFrameRejectsInvalidVideoParams)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
// A real node, but a default (zero-sized) VideoParams fails validation
|
||||||
|
// before any project resolution or decode work happens.
|
||||||
|
olive::Track track;
|
||||||
|
ASSERT_FALSE(olive::VideoParams().is_valid());
|
||||||
|
|
||||||
|
const olive::RenderTicketPtr ticket = std::make_shared<olive::RenderTicket>();
|
||||||
|
EXPECT_FALSE(
|
||||||
|
pool.SubmitFrame(ticket, MakeVideoParams(&track, olive::VideoParams())));
|
||||||
|
EXPECT_FALSE(ticket->IsRunning());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(RenderWorkerPool, SubmitFrameRejectsNonFrameReturnType)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
olive::RenderWorkerPool pool(&cache, QStringLiteral("cpu"));
|
||||||
|
|
||||||
|
olive::Track track;
|
||||||
|
olive::RenderManager::RenderVideoParams params = MakeVideoParams(
|
||||||
|
&track, olive::VideoParams(64, 64, olive::core::PixelFormat::U8, 4));
|
||||||
|
params.return_type = olive::RenderManager::kTexture;
|
||||||
|
|
||||||
|
const olive::RenderTicketPtr ticket = std::make_shared<olive::RenderTicket>();
|
||||||
|
EXPECT_FALSE(pool.SubmitFrame(ticket, params));
|
||||||
|
EXPECT_FALSE(ticket->IsRunning());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DecoderCache
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
TEST(DecoderCache, DefaultPairAndInsertRoundTrip)
|
||||||
|
{
|
||||||
|
olive::DecoderCache cache;
|
||||||
|
const olive::Decoder::CodecStream stream(
|
||||||
|
QStringLiteral("/nonexistent/source.mov"), 2, nullptr);
|
||||||
|
|
||||||
|
// Missing entries yield a default DecoderPair.
|
||||||
|
const olive::DecoderPair missing = cache.value(stream);
|
||||||
|
EXPECT_EQ(missing.decoder, nullptr);
|
||||||
|
EXPECT_EQ(missing.last_modified, 0);
|
||||||
|
|
||||||
|
olive::DecoderPair pair;
|
||||||
|
pair.last_modified = qint64(1700000000123);
|
||||||
|
cache.insert(stream, pair);
|
||||||
|
|
||||||
|
const olive::DecoderPair fetched = cache.value(stream);
|
||||||
|
EXPECT_EQ(fetched.decoder, nullptr);
|
||||||
|
EXPECT_EQ(fetched.last_modified, qint64(1700000000123));
|
||||||
|
|
||||||
|
// The cache exposes its mutex for locked access (used by the worker pool).
|
||||||
|
EXPECT_NE(cache.mutex(), nullptr);
|
||||||
|
|
||||||
|
// A different stream is an independent entry.
|
||||||
|
const olive::Decoder::CodecStream other(
|
||||||
|
QStringLiteral("/nonexistent/other.mov"), 2, nullptr);
|
||||||
|
EXPECT_EQ(cache.value(other).last_modified, 0);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user