tests: coverage round 8 (serialization, folder, text/polygon, probe, render tail)

- node_save_load_test: Node Save/Load round trips for values, keyframes,
  hints, caches, connections, positions
- node_polygon_folder_test: Folder child management, PolygonGenerator
  rasterization/gizmos, TextGeneratorV1/V2
- footage_probe_test: real FFmpeg/OIIO probing of demo.mp4/img.png,
  metadata cache, footage state transitions
- render_tail_test: DynamicRenderer, color-context shader plumbing,
  PreviewAutoCacher pause/clear paths, DiskManager edges,
  AudioPlaybackCache segment I/O

Also fixes AudioPlaybackCache::WritePartOfSampleBuffer, found by the new
tests: zero padding was written via QFile::write(const char*) which
treats the buffer as a NUL-terminated string, so no padding bytes were
ever written; the write length was also computed from the segment end
instead of the range end, making WriteSilence() spin forever on an
empty buffer
This commit is contained in:
2026-07-17 07:48:18 +08:00
parent 4302a0aca2
commit d9a4e27045
6 changed files with 3049 additions and 6 deletions
+12 -6
View File
@@ -99,7 +99,9 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples,
int64_t segment_end = segment_start + kDefaultSegmentSizePerChannel; int64_t segment_end = segment_start + kDefaultSegmentSizePerChannel;
int64_t offset_in_segment = current_cache_offset - segment_start; int64_t offset_in_segment = current_cache_offset - segment_start;
int64_t write_len = segment_end - offset_in_segment; // Never write past the end of the requested range
int64_t write_len = std::min(segment_end - current_cache_offset,
end_cache_offset - current_cache_offset);
int64_t max_buffer_len = end_buffer_offset - current_buffer_offset; int64_t max_buffer_len = end_buffer_offset - current_buffer_offset;
int64_t zero_len = 0; int64_t zero_len = 0;
@@ -119,13 +121,17 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples,
QFile f(filename); QFile f(filename);
if (f.open(QFile::ReadWrite)) { if (f.open(QFile::ReadWrite)) {
f.seek(offset_in_segment); f.seek(offset_in_segment);
f.write(reinterpret_cast<const char *>(samples.data(channel)) + if (write_len > 0) {
current_buffer_offset, f.write(reinterpret_cast<const char *>(samples.data(channel)) +
write_len); current_buffer_offset,
write_len);
}
if (zero_len > 0) { if (zero_len > 0) {
// NOTE: the length must be passed explicitly; write(const
// char*) would treat the zeros as an empty C string
QByteArray b(zero_len, 0); QByteArray b(zero_len, 0);
f.write(b.constData()); f.write(b.constData(), b.size());
} }
f.close(); f.close();
@@ -134,7 +140,7 @@ bool AudioPlaybackCache::WritePartOfSampleBuffer(const SampleBuffer &samples,
} }
} }
current_cache_offset += write_len; current_cache_offset += write_len + zero_len;
current_buffer_offset += write_len; current_buffer_offset += write_len;
} }
+4
View File
@@ -75,6 +75,10 @@ add_executable(olive-gtest
node_distort_test.cpp node_distort_test.cpp
node_filter_keying_test.cpp node_filter_keying_test.cpp
node_math_transition_test.cpp node_math_transition_test.cpp
node_save_load_test.cpp
node_polygon_folder_test.cpp
footage_probe_test.cpp
render_tail_test.cpp
timeline_marker_test.cpp timeline_marker_test.cpp
undo_stack_test.cpp undo_stack_test.cpp
plugin_support_test.cpp plugin_support_test.cpp
+486
View File
@@ -0,0 +1,486 @@
#include <gtest/gtest.h>
#include <QApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QMetaObject>
#include <QStandardPaths>
#include <QTemporaryDir>
#include <QVariant>
#include <QWidget>
#include "codec/decoder.h"
#include "common/filefunctions.h"
#include "core.h"
#include "node/color/colormanager/colormanager.h"
#include "node/globals.h"
#include "node/project.h"
#include "node/project/footage/footage.h"
#include "node/project/footage/footagedescription.h"
#include "render/diskmanager.h"
#include "render/job/footagejob.h"
#include "render/loopmode.h"
#include "render/texture.h"
namespace
{
QString DemoVideoPath()
{
return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/demo.mp4"));
}
QString TestImagePath()
{
return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/img.png"));
}
// Mirrors the cache path expression used by Footage::Reprobe()
QString MetadataCacheFileFor(const QString &media_path)
{
return QDir(QStandardPaths::writableLocation(QStandardPaths::CacheLocation))
.filePath(olive::FileFunctions::GetUniqueFileIdentifier(media_path));
}
} // namespace
TEST(FootageProbe, FFmpegProbeOfDemoMp4ReportsExpectedStreams)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::DecoderPtr decoder =
olive::Decoder::CreateFromID(QStringLiteral("ffmpeg"));
ASSERT_TRUE(decoder);
const olive::FootageDescription desc = decoder->Probe(path, nullptr);
ASSERT_TRUE(desc.IsValid());
EXPECT_EQ(desc.decoder(), QStringLiteral("ffmpeg"));
// The file holds video + audio + a timecode data track. The data track is
// counted in the total but not exposed as a usable stream.
ASSERT_EQ(desc.GetVideoStreams().size(), 1);
ASSERT_EQ(desc.GetAudioStreams().size(), 1);
EXPECT_EQ(desc.GetSubtitleStreams().size(), 0);
EXPECT_EQ(desc.GetStreamCount(), 3);
const olive::VideoParams &video = desc.GetVideoStreams().first();
EXPECT_EQ(video.stream_index(), 0);
EXPECT_EQ(video.width(), 1920);
EXPECT_EQ(video.height(), 1080);
EXPECT_EQ(video.video_type(), olive::VideoParams::kVideoTypeVideo);
EXPECT_EQ(video.interlacing(), olive::VideoParams::kInterlaceNone);
EXPECT_EQ(video.pixel_aspect_ratio(), olive::rational(1, 1));
EXPECT_EQ(video.frame_rate(), olive::rational(25));
EXPECT_EQ(video.time_base(), olive::rational(1, 12800));
EXPECT_EQ(video.duration(), 217600); // 17 seconds at 1/12800
EXPECT_NE(video.format(), olive::core::PixelFormat::INVALID);
EXPECT_GT(video.channel_count(), 0);
const olive::core::AudioParams &audio = desc.GetAudioStreams().first();
EXPECT_EQ(audio.stream_index(), 1);
EXPECT_EQ(audio.sample_rate(), 48000);
EXPECT_EQ(audio.channel_count(), 2);
EXPECT_EQ(audio.time_base(), olive::rational(1, 48000));
EXPECT_EQ(audio.duration(), 816000); // 17 seconds at 1/48000
// The file's timecode track starts at 01:00:00:00
ASSERT_TRUE(desc.HasSourceStartTime());
EXPECT_EQ(desc.source_start_time(), olive::rational(3600));
EXPECT_EQ(desc.source_start_time_source(), QStringLiteral("timecode"));
}
TEST(FootageProbe, ProbeOfUnprobeableFileYieldsInvalidDescription)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path =
QDir(dir.path()).filePath(QStringLiteral("not_media.txt"));
{
QFile file(path);
ASSERT_TRUE(file.open(QFile::WriteOnly));
file.write("this is not a media file");
}
for (const olive::DecoderPtr &decoder :
olive::Decoder::ReceiveListOfAllDecoders()) {
EXPECT_FALSE(decoder->Probe(path, nullptr).IsValid())
<< decoder->id().toStdString();
}
}
class FootageProbeTest : public ::testing::Test {
protected:
void SetUp() override
{
if (!olive::Core::instance()) {
// Leaked intentionally: Core is process-wide (matches footage_test)
new olive::Core(olive::Core::CoreParams());
}
// Footage::Value() resolves Project::cache_path(), which goes through
// the DiskManager singleton
created_disk_manager_ = (olive::DiskManager::instance() == nullptr);
if (created_disk_manager_) {
olive::DiskManager::CreateInstance();
}
// Sandbox the footage metadata cache so real probes write into the
// temp dir instead of the user's cache
old_cache_home_ = qgetenv("XDG_CACHE_HOME");
had_cache_home_ = qEnvironmentVariableIsSet("XDG_CACHE_HOME");
qputenv("XDG_CACHE_HOME",
QDir(temp_dir_.path()).filePath(QStringLiteral("xdg")).toUtf8());
QDir().mkpath(
QStandardPaths::writableLocation(QStandardPaths::CacheLocation));
olive::ColorManager::SetUpDefaultConfig();
project_ = std::make_unique<olive::Project>();
project_->Initialize();
}
void TearDown() override
{
project_.reset();
if (created_disk_manager_) {
olive::DiskManager::DestroyInstance();
}
if (had_cache_home_) {
qputenv("XDG_CACHE_HOME", old_cache_home_);
} else {
qunsetenv("XDG_CACHE_HOME");
}
}
// Constructs a Footage pointing at path; the constructor's set_filename()
// call probes the file synchronously before the node joins the graph
olive::Footage *AddProbedFootage(const QString &path)
{
auto *footage = new olive::Footage(path);
footage->setParent(project_.get());
return footage;
}
QTemporaryDir temp_dir_;
QByteArray old_cache_home_;
bool had_cache_home_ = false;
bool created_disk_manager_ = false;
std::unique_ptr<olive::Project> project_;
};
TEST_F(FootageProbeTest, ProbingDemoMp4PopulatesFootageState)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *footage = AddProbedFootage(path);
EXPECT_TRUE(footage->IsValid());
EXPECT_EQ(footage->decoder(), QStringLiteral("ffmpeg"));
EXPECT_EQ(footage->timestamp(),
QFileInfo(path).lastModified().toMSecsSinceEpoch());
// Video + audio streams are usable; the timecode data track only shows up
// in the total stream count
EXPECT_EQ(footage->GetTotalStreamCount(), 3);
EXPECT_EQ(footage->GetVideoStreamCount(), 1);
EXPECT_EQ(footage->GetAudioStreamCount(), 1);
EXPECT_EQ(footage->GetSubtitleStreamCount(), 0);
EXPECT_EQ(footage->GetStreamIndex(olive::Track::kVideo, 0), 0);
EXPECT_EQ(footage->GetStreamIndex(olive::Track::kAudio, 0), 1);
EXPECT_EQ(footage->GetReferenceFromRealIndex(0),
olive::Track::Reference(olive::Track::kVideo, 0));
EXPECT_EQ(footage->GetReferenceFromRealIndex(1),
olive::Track::Reference(olive::Track::kAudio, 0));
EXPECT_EQ(footage->GetReferenceFromRealIndex(2).type(),
olive::Track::kNone);
EXPECT_EQ(footage->GetConnectedTextureOutput(),
static_cast<olive::Node *>(footage));
EXPECT_EQ(footage->GetConnectedSampleOutput(),
static_cast<olive::Node *>(footage));
const olive::VideoParams video = footage->GetVideoParams(0);
ASSERT_TRUE(video.is_valid());
EXPECT_EQ(video.stream_index(), 0);
EXPECT_EQ(video.width(), 1920);
EXPECT_EQ(video.height(), 1080);
EXPECT_EQ(video.video_type(), olive::VideoParams::kVideoTypeVideo);
EXPECT_EQ(video.frame_rate(), olive::rational(25));
EXPECT_EQ(video.time_base(), olive::rational(1, 12800));
EXPECT_EQ(video.duration(), 217600);
EXPECT_EQ(video.color_range(), olive::VideoParams::kColorRangeLimited);
EXPECT_TRUE(video.enabled());
// The FFmpeg probe leaves colorspace unset so the project default applies
EXPECT_TRUE(video.colorspace().isEmpty());
const olive::core::AudioParams audio = footage->GetAudioParams(0);
ASSERT_TRUE(audio.is_valid());
EXPECT_EQ(audio.stream_index(), 1);
EXPECT_EQ(audio.sample_rate(), 48000);
EXPECT_EQ(audio.channel_count(), 2);
EXPECT_EQ(audio.duration(), 816000);
EXPECT_TRUE(audio.enabled());
}
TEST_F(FootageProbeTest, ProbingDemoMp4SetsLengthsAndSourceStartTime)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *footage = AddProbedFootage(path);
footage->VerifyLength();
// Both streams describe 17 seconds of media
EXPECT_EQ(footage->GetVideoLength(), olive::rational(17));
EXPECT_EQ(footage->GetAudioLength(), olive::rational(17));
EXPECT_EQ(footage->GetLength(), olive::rational(17));
// The embedded 01:00:00:00 timecode becomes the source start time
ASSERT_TRUE(footage->HasSourceStartTime());
EXPECT_EQ(footage->source_start_time(), olive::rational(3600));
EXPECT_EQ(footage->source_start_time_source(), QStringLiteral("timecode"));
}
TEST_F(FootageProbeTest, ProbingPngImageProducesSingleStillStream)
{
const QString path = TestImagePath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *footage = AddProbedFootage(path);
EXPECT_TRUE(footage->IsValid());
// Still images are handled by the OIIO decoder, which probes before FFmpeg
EXPECT_EQ(footage->decoder(), QStringLiteral("oiio"));
EXPECT_EQ(footage->GetTotalStreamCount(), 1);
EXPECT_EQ(footage->GetVideoStreamCount(), 1);
EXPECT_EQ(footage->GetAudioStreamCount(), 0);
EXPECT_EQ(footage->GetSubtitleStreamCount(), 0);
const olive::VideoParams still = footage->GetVideoParams(0);
ASSERT_TRUE(still.is_valid());
EXPECT_EQ(still.stream_index(), 0);
EXPECT_EQ(still.width(), 1920);
EXPECT_EQ(still.height(), 1080);
EXPECT_EQ(still.video_type(), olive::VideoParams::kVideoTypeStill);
EXPECT_EQ(still.channel_count(), 4);
EXPECT_EQ(still.format(), olive::core::PixelFormat::U8);
EXPECT_TRUE(still.premultiplied_alpha());
EXPECT_TRUE(still.enabled());
EXPECT_TRUE(still.colorspace().isEmpty());
// Stills have no duration and no source start time
footage->VerifyLength();
EXPECT_EQ(footage->GetVideoLength(), olive::rational(0));
EXPECT_EQ(footage->GetLength(), olive::rational(0));
EXPECT_FALSE(footage->HasSourceStartTime());
EXPECT_EQ(footage->GetConnectedTextureOutput(),
static_cast<olive::Node *>(footage));
EXPECT_EQ(footage->GetConnectedSampleOutput(), nullptr);
}
TEST_F(FootageProbeTest, ProbedFootageValuePushesRealStreamJobs)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *footage = AddProbedFootage(path);
footage->VerifyLength();
// The colorspace fallback reads the project default, and the audio cache
// path comes from the project's cache settings
project_->SetDefaultInputColorSpace(QStringLiteral("ProbeInputSpace"));
project_->SetCacheLocationSetting(olive::Project::kCacheCustomPath);
const QString cache_path =
QDir(temp_dir_.path()).filePath(QStringLiteral("cache"));
project_->SetCustomCachePath(cache_path);
olive::NodeValueRow row;
row.insert(olive::Footage::kFilenameInput,
olive::NodeValue(olive::NodeValue::kFile, path));
olive::VideoParams vparams(64, 64, olive::rational(1, 24),
olive::core::PixelFormat::U8, 4);
const olive::NodeGlobals globals(vparams, olive::core::AudioParams(),
olive::rational(0),
olive::LoopMode::kLoopModeOff);
olive::NodeValueTable table;
footage->Value(row, globals, &table);
// Length, one texture job for the video stream, one sample job for the
// audio stream; the timecode data track produces no job
ASSERT_EQ(table.Count(), 3);
const olive::NodeValue length =
table.Get(olive::NodeValue::kRational, QStringLiteral("length"));
ASSERT_EQ(length.type(), olive::NodeValue::kRational);
EXPECT_EQ(length.toRational(), olive::rational(17));
const olive::TexturePtr texture =
table.Get(olive::NodeValue::kTexture, QStringLiteral("v:0"))
.toTexture();
ASSERT_NE(texture, nullptr);
EXPECT_EQ(texture->params().width(), 1920);
EXPECT_EQ(texture->params().height(), 1080);
// The probed stream has no colorspace, so the project default is used
EXPECT_EQ(texture->params().colorspace(),
QStringLiteral("ProbeInputSpace"));
const auto *video_job =
static_cast<const olive::FootageJob *>(texture->job());
ASSERT_NE(video_job, nullptr);
EXPECT_EQ(video_job->decoder(), QStringLiteral("ffmpeg"));
EXPECT_EQ(video_job->filename(), path);
EXPECT_EQ(video_job->type(), olive::Track::kVideo);
EXPECT_EQ(video_job->length(), olive::rational(17));
const olive::FootageJob audio_job =
table.Get(olive::NodeValue::kSamples, QStringLiteral("a:0"))
.data()
.value<olive::FootageJob>();
EXPECT_EQ(audio_job.decoder(), QStringLiteral("ffmpeg"));
EXPECT_EQ(audio_job.filename(), path);
EXPECT_EQ(audio_job.type(), olive::Track::kAudio);
EXPECT_EQ(audio_job.audio_params().sample_rate(), 48000);
EXPECT_EQ(audio_job.length(), olive::rational(17));
EXPECT_EQ(audio_job.cache_path(), cache_path);
}
TEST_F(FootageProbeTest, SecondProbeReadsBackMetadataCache)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *first = AddProbedFootage(path);
ASSERT_TRUE(first->IsValid());
// The first probe writes a stream metadata cache into the cache location
const QString cache_file = MetadataCacheFileFor(path);
ASSERT_TRUE(QFileInfo::exists(cache_file));
// A second footage for the same file loads its metadata from that cache
// and ends up with identical state
olive::Footage *second = AddProbedFootage(path);
ASSERT_TRUE(second->IsValid());
EXPECT_EQ(second->decoder(), first->decoder());
EXPECT_EQ(second->GetTotalStreamCount(), first->GetTotalStreamCount());
EXPECT_EQ(second->GetVideoStreamCount(), first->GetVideoStreamCount());
EXPECT_EQ(second->GetAudioStreamCount(), first->GetAudioStreamCount());
const olive::VideoParams from_cache = second->GetVideoParams(0);
const olive::VideoParams probed = first->GetVideoParams(0);
EXPECT_EQ(from_cache.stream_index(), probed.stream_index());
EXPECT_EQ(from_cache.width(), probed.width());
EXPECT_EQ(from_cache.height(), probed.height());
EXPECT_EQ(from_cache.frame_rate(), probed.frame_rate());
EXPECT_EQ(from_cache.time_base(), probed.time_base());
EXPECT_EQ(from_cache.duration(), probed.duration());
EXPECT_EQ(from_cache.video_type(), probed.video_type());
ASSERT_TRUE(second->HasSourceStartTime());
EXPECT_EQ(second->source_start_time(), olive::rational(3600));
EXPECT_EQ(second->source_start_time_source(), QStringLiteral("timecode"));
}
TEST_F(FootageProbeTest, FilenameChangeToMissingFileClearsProbeState)
{
const QString path = DemoVideoPath();
ASSERT_TRUE(QFileInfo::exists(path));
olive::Footage *footage = AddProbedFootage(path);
ASSERT_TRUE(footage->IsValid());
ASSERT_GT(footage->GetTotalStreamCount(), 0);
// Pointing the footage at a nonexistent file clears the probed state and
// the re-probe fails
footage->set_filename(
QDir(temp_dir_.path()).filePath(QStringLiteral("gone.mp4")));
EXPECT_FALSE(footage->IsValid());
EXPECT_EQ(footage->GetTotalStreamCount(), 0);
EXPECT_EQ(footage->GetVideoStreamCount(), 0);
EXPECT_EQ(footage->GetAudioStreamCount(), 0);
EXPECT_TRUE(footage->decoder().isEmpty());
EXPECT_EQ(footage->timestamp(), 0);
EXPECT_FALSE(footage->HasSourceStartTime());
}
TEST_F(FootageProbeTest, CheckFootageOnlyRespondsWithActiveWindow)
{
const QString path = TestImagePath();
ASSERT_TRUE(QFileInfo::exists(path));
// Work on a copy so the original test asset is untouched
const QString copy =
QDir(temp_dir_.path()).filePath(QStringLiteral("image.png"));
ASSERT_TRUE(QFile::copy(path, copy));
olive::Footage *footage = AddProbedFootage(copy);
ASSERT_TRUE(footage->IsValid());
const qint64 probed_timestamp = footage->timestamp();
ASSERT_GT(probed_timestamp, 0);
// The file vanishes behind the footage's back
ASSERT_TRUE(QFile::remove(copy));
// Without an active window, CheckFootage is a no-op
ASSERT_TRUE(
QMetaObject::invokeMethod(footage, "CheckFootage", Qt::DirectConnection));
EXPECT_EQ(footage->timestamp(), probed_timestamp);
EXPECT_TRUE(footage->IsValid());
// With an active window, CheckFootage notices the missing file and
// re-probes. The re-probe resets the timestamp but, because Reprobe()
// never clears existing state for a missing file, the (now stale) probe
// data is kept until the filename itself changes.
{
QWidget window;
window.show();
window.activateWindow();
QCoreApplication::processEvents();
ASSERT_EQ(qApp->activeWindow(), &window);
ASSERT_TRUE(QMetaObject::invokeMethod(footage, "CheckFootage",
Qt::DirectConnection));
}
ASSERT_EQ(qApp->activeWindow(), nullptr);
EXPECT_EQ(footage->timestamp(), 0);
EXPECT_TRUE(footage->IsValid());
EXPECT_EQ(footage->GetVideoStreamCount(), 1);
}
TEST_F(FootageProbeTest, ProbingExistingButInvalidMediaStaysInvalid)
{
const QString path =
QDir(temp_dir_.path()).filePath(QStringLiteral("fake.mkv"));
{
QFile file(path);
ASSERT_TRUE(file.open(QFile::WriteOnly));
file.write("OAK_FAKE_MEDIA");
}
olive::Footage *footage = AddProbedFootage(path);
// The file exists but no decoder can probe it, so the footage stays
// invalid
EXPECT_FALSE(footage->IsValid());
EXPECT_TRUE(footage->decoder().isEmpty());
EXPECT_EQ(footage->GetTotalStreamCount(), 0);
EXPECT_EQ(footage->GetVideoStreamCount(), 0);
EXPECT_EQ(footage->GetAudioStreamCount(), 0);
EXPECT_EQ(footage->GetSubtitleStreamCount(), 0);
// Note: Reprobe caches even this failed probe result, so future reprobes
// of the same path reload the invalid description instead of re-probing
EXPECT_TRUE(QFileInfo::exists(MetadataCacheFileFor(path)));
}
File diff suppressed because it is too large Load Diff
+619
View File
@@ -0,0 +1,619 @@
#include <gtest/gtest.h>
#include <memory>
#include <QPointF>
#include <QString>
#include <QUuid>
#include <QVector>
#include <QXmlStreamReader>
#include <QXmlStreamWriter>
#include "common/xmlutils.h"
#include "core.h"
#include "node/color/colormanager/colormanager.h"
#include "node/factory.h"
#include "node/generator/solid/solid.h"
#include "node/generator/text/textv3.h"
#include "node/keyframe.h"
#include "node/math/math/math.h"
#include "node/node.h"
#include "node/project.h"
#include "node/project/folder/folder.h"
#include "node/serializeddata.h"
#include "node/splitvalue.h"
#include "render/diskmanager.h"
namespace
{
// Serializes a single node into a standalone XML document, mirroring how
// Project::Save wraps Node::Save in a "node" element
QString SaveNodeXml(const olive::Node *node)
{
QString xml;
QXmlStreamWriter writer(&xml);
writer.writeStartDocument();
writer.writeStartElement(QStringLiteral("node"));
node->Save(&writer);
writer.writeEndElement(); // node
writer.writeEndDocument();
return xml;
}
// Loads a document produced by SaveNodeXml into an existing node
bool LoadNodeXml(olive::Node *node, const QString &xml,
olive::SerializedData *data)
{
QXmlStreamReader reader(xml);
if (!reader.readNextStartElement()) {
return false;
}
if (reader.name() != QStringLiteral("node")) {
return false;
}
return node->Load(&reader, data);
}
olive::Node *FindNodeById(olive::Project *project, const QString &id)
{
for (olive::Node *n : project->nodes()) {
if (n->id() == id) {
return n;
}
}
return nullptr;
}
// Node that round-trips a custom payload through SaveCustom/LoadCustom and
// records LoadFinishedEvent
class CustomDataNode : public olive::Node {
public:
CustomDataNode()
{
AddInput(QStringLiteral("Value"), olive::NodeValue::kFloat);
}
NODE_DEFAULT_FUNCTIONS(CustomDataNode)
virtual QString Name() const override
{
return QStringLiteral("CustomDataNode");
}
virtual QString id() const override
{
return QStringLiteral("org.oak.test.customdatanode");
}
virtual QVector<CategoryID> Category() const override
{
return { kCategoryUnknown };
}
virtual QString Description() const override
{
return QStringLiteral("Node with custom serialized data");
}
void Value(const olive::NodeValueRow &, const olive::NodeGlobals &,
olive::NodeValueTable *) const override
{
}
virtual void SaveCustom(QXmlStreamWriter *writer) const override
{
writer->writeTextElement(QStringLiteral("greeting"), greeting_);
}
virtual bool LoadCustom(QXmlStreamReader *reader,
olive::SerializedData *data) override
{
Q_UNUSED(data)
while (olive::XMLReadNextStartElement(reader)) {
if (reader->name() == QStringLiteral("greeting")) {
greeting_ = reader->readElementText();
} else if (reader->name() == QStringLiteral("explode")) {
reader->skipCurrentElement();
return false;
} else {
reader->skipCurrentElement();
}
}
return true;
}
virtual void LoadFinishedEvent() override
{
load_finished_called_ = true;
}
QString greeting_;
bool load_finished_called_ = false;
};
} // namespace
class NodeSaveLoadTest : public ::testing::Test {
protected:
void SetUp() override
{
olive::ColorManager::SetUpDefaultConfig();
// Cache UUID changes resolve a cache path through the DiskManager
// singleton, which itself touches Core (same pattern as
// project_factory_test)
if (!olive::Core::instance()) {
new olive::Core(olive::Core::CoreParams()); // intentionally leaked
}
if (!olive::DiskManager::instance()) {
olive::DiskManager::CreateInstance();
}
project_ = std::make_unique<olive::Project>();
project_->Initialize();
}
template <typename T> T *AddNode()
{
T *node = new T();
node->setParent(project_.get());
return node;
}
std::unique_ptr<olive::Project> project_;
};
TEST_F(NodeSaveLoadTest, StandardValuesLabelAndColorRoundTrip)
{
auto *src = AddNode<olive::MathNode>();
src->SetLabel(QStringLiteral("Labeled"));
src->SetOverrideColor(3);
src->SetStandardValue(olive::MathNode::kParamAIn, 3.5);
src->SetStandardValue(olive::MathNode::kParamBIn, -2.25);
src->SetOperation(olive::MathNode::kOpMultiply);
const QString xml = SaveNodeXml(src);
EXPECT_TRUE(xml.contains(QStringLiteral("version=\"1\"")));
EXPECT_TRUE(xml.contains(
QStringLiteral("id=\"org.olivevideoeditor.Olive.math\"")));
olive::MathNode loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
EXPECT_EQ(loaded.GetLabel(), QStringLiteral("Labeled"));
EXPECT_EQ(loaded.GetOverrideColor(), 3);
EXPECT_DOUBLE_EQ(
loaded.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 3.5);
EXPECT_DOUBLE_EQ(
loaded.GetStandardValue(olive::MathNode::kParamBIn).toDouble(), -2.25);
EXPECT_EQ(int(loaded.GetOperation()), int(olive::MathNode::kOpMultiply));
// The "ptr" attribute maps the serialized address to the loaded instance
EXPECT_EQ(data.node_ptrs.value(reinterpret_cast<quintptr>(src)), &loaded);
// A non-keyframable input never reports keyframing after load
EXPECT_FALSE(loaded.IsInputKeyframing(olive::MathNode::kMethodIn));
}
TEST_F(NodeSaveLoadTest, ArrayElementsAndPerElementKeyframingRoundTrip)
{
auto *src = AddNode<olive::TextGeneratorV3>();
src->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2);
src->SetStandardValue(
olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 0),
QStringLiteral("first"));
src->SetStandardValue(
olive::NodeInput(src, olive::TextGeneratorV3::kArgsInput, 1),
QStringLiteral("second"));
// Only element 1 is keyframed
src->SetInputIsKeyframing(olive::TextGeneratorV3::kArgsInput, true, 1);
auto *key = new olive::NodeKeyframe(
olive::rational(2), QStringLiteral("keyed"), olive::NodeKeyframe::kLinear,
0, 1, olive::TextGeneratorV3::kArgsInput);
key->setParent(src);
const QString xml = SaveNodeXml(src);
olive::TextGeneratorV3 loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
// The subelement count attribute resized the array on load
ASSERT_EQ(loaded.InputArraySize(olive::TextGeneratorV3::kArgsInput), 2);
EXPECT_EQ(loaded.GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 0)
.at(0)
.toString(),
QStringLiteral("first"));
EXPECT_EQ(loaded.GetSplitStandardValue(olive::TextGeneratorV3::kArgsInput, 1)
.at(0)
.toString(),
QStringLiteral("second"));
EXPECT_FALSE(
loaded.IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 0));
EXPECT_TRUE(loaded.IsInputKeyframing(olive::TextGeneratorV3::kArgsInput, 1));
const QVector<olive::NodeKeyframeTrack> &tracks =
loaded.GetKeyframeTracks(olive::TextGeneratorV3::kArgsInput, 1);
ASSERT_EQ(tracks.at(0).size(), 1);
EXPECT_EQ(tracks.at(0).first()->time(), olive::rational(2));
EXPECT_EQ(tracks.at(0).first()->value().toString(),
QStringLiteral("keyed"));
EXPECT_EQ(tracks.at(0).first()->element(), 1);
}
TEST_F(NodeSaveLoadTest, KeyframesAllTypesAndColorPropertiesRoundTrip)
{
auto *src = AddNode<olive::SolidGenerator>();
olive::SplitValue color;
color.append(0.25);
color.append(0.5);
color.append(0.75);
color.append(1.0);
src->SetSplitStandardValue(olive::SolidGenerator::kColorInput, color, -1);
src->SetInputIsKeyframing(olive::SolidGenerator::kColorInput, true);
auto *linear = new olive::NodeKeyframe(
olive::rational(0), 0.0, olive::NodeKeyframe::kLinear, 0, -1,
olive::SolidGenerator::kColorInput);
linear->setParent(src);
auto *bezier = new olive::NodeKeyframe(
olive::rational(5), 1.0, olive::NodeKeyframe::kBezier, 0, -1,
olive::SolidGenerator::kColorInput);
bezier->setParent(src);
bezier->set_bezier_control_in(QPointF(0.25, -1.5));
bezier->set_bezier_control_out(QPointF(2.5, 0.75));
auto *hold = new olive::NodeKeyframe(
olive::rational(3), 0.5, olive::NodeKeyframe::kHold, 2, -1,
olive::SolidGenerator::kColorInput);
hold->setParent(src);
// Color inputs additionally serialize their color management properties
src->SetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_input"), QStringLiteral("ACEScg"));
src->SetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_display"), QStringLiteral("sRGB"));
src->SetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_view"), QStringLiteral("Filmic"));
src->SetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_look"), QStringLiteral("None"));
const QString xml = SaveNodeXml(src);
olive::SolidGenerator loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
EXPECT_TRUE(loaded.IsInputKeyframing(olive::SolidGenerator::kColorInput));
const QVector<olive::NodeKeyframeTrack> &tracks =
loaded.GetKeyframeTracks(olive::SolidGenerator::kColorInput, -1);
ASSERT_EQ(tracks.size(), 4);
// Track 0 holds the linear and bezier keys, sorted by time
ASSERT_EQ(tracks.at(0).size(), 2);
EXPECT_EQ(tracks.at(0).at(0)->time(), olive::rational(0));
EXPECT_EQ(tracks.at(0).at(0)->type(), olive::NodeKeyframe::kLinear);
EXPECT_DOUBLE_EQ(tracks.at(0).at(0)->value().toDouble(), 0.0);
EXPECT_EQ(tracks.at(0).at(1)->time(), olive::rational(5));
EXPECT_EQ(tracks.at(0).at(1)->type(), olive::NodeKeyframe::kBezier);
EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->value().toDouble(), 1.0);
EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_in().x(), 0.25);
EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_in().y(), -1.5);
EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_out().x(), 2.5);
EXPECT_DOUBLE_EQ(tracks.at(0).at(1)->bezier_control_out().y(), 0.75);
// Track 1 was left empty, track 2 holds the single hold key
EXPECT_TRUE(tracks.at(1).isEmpty());
ASSERT_EQ(tracks.at(2).size(), 1);
EXPECT_EQ(tracks.at(2).first()->time(), olive::rational(3));
EXPECT_EQ(tracks.at(2).first()->type(), olive::NodeKeyframe::kHold);
EXPECT_DOUBLE_EQ(tracks.at(2).first()->value().toDouble(), 0.5);
EXPECT_TRUE(tracks.at(3).isEmpty());
// The per-track standard values survive as well
const olive::SplitValue loaded_color =
loaded.GetSplitStandardValue(olive::SolidGenerator::kColorInput, -1);
ASSERT_EQ(loaded_color.size(), 4);
EXPECT_DOUBLE_EQ(loaded_color.at(0).toDouble(), 0.25);
EXPECT_DOUBLE_EQ(loaded_color.at(1).toDouble(), 0.5);
EXPECT_DOUBLE_EQ(loaded_color.at(2).toDouble(), 0.75);
EXPECT_DOUBLE_EQ(loaded_color.at(3).toDouble(), 1.0);
EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_input"))
.toString(),
QStringLiteral("ACEScg"));
EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_display"))
.toString(),
QStringLiteral("sRGB"));
EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_view"))
.toString(),
QStringLiteral("Filmic"));
EXPECT_EQ(loaded.GetInputProperty(olive::SolidGenerator::kColorInput,
QStringLiteral("col_look"))
.toString(),
QStringLiteral("None"));
}
TEST_F(NodeSaveLoadTest, ValueHintsRoundTrip)
{
auto *src = AddNode<olive::MathNode>();
src->SetValueHintForInput(
olive::MathNode::kParamAIn,
olive::Node::ValueHint(
{ olive::NodeValue::kVec2, olive::NodeValue::kTexture }, 3,
QStringLiteral("tag")));
src->SetValueHintForInput(olive::MathNode::kParamBIn,
olive::Node::ValueHint(QStringLiteral("elem")), 2);
const QString xml = SaveNodeXml(src);
olive::MathNode loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
const olive::Node::ValueHint hint =
loaded.GetValueHintForInput(olive::MathNode::kParamAIn);
ASSERT_EQ(hint.types().size(), 2);
EXPECT_EQ(hint.types().at(0), olive::NodeValue::kVec2);
EXPECT_EQ(hint.types().at(1), olive::NodeValue::kTexture);
EXPECT_EQ(hint.index(), 3);
EXPECT_EQ(hint.tag(), QStringLiteral("tag"));
// Hints are tracked per element
EXPECT_EQ(loaded.GetValueHintForInput(olive::MathNode::kParamBIn, 2).tag(),
QStringLiteral("elem"));
EXPECT_EQ(loaded.GetValueHintForInput(olive::MathNode::kParamBIn, 1).tag(),
QString());
EXPECT_EQ(loaded.GetValueHints().size(), 2);
}
TEST_F(NodeSaveLoadTest, CacheUuidsRoundTrip)
{
olive::MathNode src;
const QUuid audio_uuid(
QStringLiteral("{11111111-1111-1111-1111-111111111111}"));
const QUuid video_uuid(
QStringLiteral("{22222222-2222-2222-2222-222222222222}"));
const QUuid thumb_uuid(
QStringLiteral("{33333333-3333-3333-3333-333333333333}"));
const QUuid waveform_uuid(
QStringLiteral("{44444444-4444-4444-4444-444444444444}"));
src.audio_playback_cache()->SetUuid(audio_uuid);
src.video_frame_cache()->SetUuid(video_uuid);
src.thumbnail_cache()->SetUuid(thumb_uuid);
src.waveform_cache()->SetUuid(waveform_uuid);
const QString xml = SaveNodeXml(&src);
olive::MathNode loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
EXPECT_EQ(loaded.audio_playback_cache()->GetUuid(), audio_uuid);
EXPECT_EQ(loaded.video_frame_cache()->GetUuid(), video_uuid);
EXPECT_EQ(loaded.thumbnail_cache()->GetUuid(), thumb_uuid);
EXPECT_EQ(loaded.waveform_cache()->GetUuid(), waveform_uuid);
}
TEST_F(NodeSaveLoadTest, CustomDataAndLoadFinishedEventRoundTrip)
{
CustomDataNode src;
src.greeting_ = QStringLiteral("hello custom");
const QString xml = SaveNodeXml(&src);
EXPECT_TRUE(xml.contains(QStringLiteral("hello custom")));
CustomDataNode loaded;
olive::SerializedData data;
ASSERT_TRUE(LoadNodeXml(&loaded, xml, &data));
EXPECT_EQ(loaded.greeting_, QStringLiteral("hello custom"));
EXPECT_TRUE(loaded.load_finished_called_);
// A LoadCustom failure propagates out of Node::Load
const QString fail_xml = QStringLiteral(
"<node><custom><explode/></custom></node>");
CustomDataNode failing;
olive::SerializedData fail_data;
QXmlStreamReader reader(fail_xml);
ASSERT_TRUE(reader.readNextStartElement());
ASSERT_EQ(reader.name(), QStringLiteral("node"));
EXPECT_FALSE(failing.Load(&reader, &fail_data));
}
TEST_F(NodeSaveLoadTest, UnknownElementsAndVersionAreSkipped)
{
olive::MathNode node;
// Unknown elements are skipped at every level of the node format, and an
// unrecognized version attribute does not fail the load
const QString xml = QStringLiteral(
"<node version=\"999\" id=\"org.olivevideoeditor.Olive.math\">"
"<mystery><nested attr=\"1\"/></mystery>"
"<label>kept</label>"
"<links><strange/></links>"
"<connections>"
"<strange/>"
"<connection input=\"param_a_in\" element=\"-1\">"
"<weird/>"
"<output>12345</output>"
"</connection>"
"</connections>"
"<hints><strange/></hints>"
"<context><strange/></context>"
"<caches>"
"<mysterycache>{00000000-0000-0000-0000-000000000000}</mysterycache>"
"</caches>"
"<input id=\"param_a_in\"><oddstandard/></input>"
"</node>");
olive::SerializedData data;
QXmlStreamReader reader(xml);
ASSERT_TRUE(reader.readNextStartElement());
ASSERT_EQ(reader.name(), QStringLiteral("node"));
EXPECT_TRUE(node.Load(&reader, &data));
EXPECT_EQ(node.GetLabel(), QStringLiteral("kept"));
// The one well-formed connection was still recorded
ASSERT_EQ(data.desired_connections.size(), 1);
EXPECT_EQ(data.desired_connections.first().input.input(),
olive::MathNode::kParamAIn);
EXPECT_EQ(data.desired_connections.first().input.element(), -1);
EXPECT_EQ(data.desired_connections.first().output_node, quintptr(12345));
// The malformed input left the default value untouched
EXPECT_DOUBLE_EQ(
node.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0);
}
TEST_F(NodeSaveLoadTest, LoadInputWithMissingOrUnknownIdIsSkipped)
{
olive::MathNode node;
// An input with no id and an input whose id does not exist on the node
// both make LoadInput fail internally, but Node::Load ignores that return
// value and carries on
const QString xml = QStringLiteral(
"<node>"
"<input><primary><standard><track>9</track></standard></primary></input>"
"<input id=\"no_such_input\">"
"<primary><standard><track>9</track></standard></primary>"
"</input>"
"</node>");
olive::SerializedData data;
QXmlStreamReader reader(xml);
ASSERT_TRUE(reader.readNextStartElement());
ASSERT_EQ(reader.name(), QStringLiteral("node"));
EXPECT_TRUE(node.Load(&reader, &data));
EXPECT_DOUBLE_EQ(
node.GetStandardValue(olive::MathNode::kParamAIn).toDouble(), 0.0);
EXPECT_DOUBLE_EQ(
node.GetStandardValue(olive::MathNode::kParamBIn).toDouble(), 0.0);
}
TEST_F(NodeSaveLoadTest, ConnectionsLinksAndPositionsResolveAfterProjectLoad)
{
olive::NodeFactory::Initialize();
auto *src = AddNode<olive::SolidGenerator>();
auto *dst = AddNode<olive::MathNode>();
auto *text = AddNode<olive::TextGeneratorV3>();
text->InputArrayResize(olive::TextGeneratorV3::kArgsInput, 2);
olive::Node::ConnectEdge(
src, olive::NodeInput(dst, olive::MathNode::kParamAIn));
olive::Node::ConnectEdge(
dst, olive::NodeInput(text, olive::TextGeneratorV3::kArgsInput, 1));
olive::Node::Link(src, dst);
olive::Folder *root = project_->root();
root->SetNodePositionInContext(
src, olive::Node::Position(QPointF(10.0, 20.0), true));
root->SetNodePositionInContext(
dst, olive::Node::Position(QPointF(-3.5, 7.25), false));
QString xml;
QXmlStreamWriter writer(&xml);
writer.writeStartDocument();
writer.writeStartElement(QStringLiteral("project"));
project_->Save(&writer);
writer.writeEndElement(); // project
writer.writeEndDocument();
// The project being loaded into must not be Initialize()d: Load()
// re-resolves the root folder from the saved settings
olive::Project loaded;
olive::SerializedData data;
{
QXmlStreamReader reader(xml);
ASSERT_TRUE(reader.readNextStartElement());
ASSERT_EQ(reader.name(), QStringLiteral("project"));
data = loaded.Load(&reader);
}
// Root folder plus the three nodes created above
ASSERT_EQ(loaded.nodes().size(), 4);
olive::Node *loaded_src = FindNodeById(&loaded, src->id());
olive::Node *loaded_dst = FindNodeById(&loaded, dst->id());
olive::Node *loaded_text = FindNodeById(&loaded, text->id());
ASSERT_NE(loaded_src, nullptr);
ASSERT_NE(loaded_dst, nullptr);
ASSERT_NE(loaded_text, nullptr);
// Both edges were recorded against the serialized addresses, including
// the array element index on the text input
ASSERT_EQ(data.desired_connections.size(), 2);
bool found_math_edge = false;
bool found_text_edge = false;
for (const auto &sc : data.desired_connections) {
if (sc.input.node() == loaded_dst) {
EXPECT_EQ(sc.input.input(), olive::MathNode::kParamAIn);
EXPECT_EQ(sc.input.element(), -1);
EXPECT_EQ(sc.output_node, reinterpret_cast<quintptr>(src));
found_math_edge = true;
} else if (sc.input.node() == loaded_text) {
EXPECT_EQ(sc.input.input(), olive::TextGeneratorV3::kArgsInput);
EXPECT_EQ(sc.input.element(), 1);
EXPECT_EQ(sc.output_node, reinterpret_cast<quintptr>(dst));
found_text_edge = true;
}
}
EXPECT_TRUE(found_math_edge);
EXPECT_TRUE(found_text_edge);
// Both nodes wrote their side of the link
EXPECT_EQ(data.block_links.size(), 2);
// The root folder recorded positions for the two placed nodes
olive::Folder *loaded_root = loaded.root();
ASSERT_NE(loaded_root, nullptr);
EXPECT_EQ(data.positions.value(loaded_root).size(), 2);
// Resolve the deferred state the same way
// ProjectSerializer230220::PostConnect does
for (const auto &sc : data.desired_connections) {
if (olive::Node *out = data.node_ptrs.value(sc.output_node)) {
olive::Node::ConnectEdge(out, sc.input);
}
}
for (const auto &link : data.block_links) {
olive::Node::Link(link.block, data.node_ptrs.value(link.link));
}
for (olive::Node *n : loaded.nodes()) {
n->PostLoadEvent(&data);
}
EXPECT_EQ(loaded_dst->GetConnectedOutput(olive::MathNode::kParamAIn),
loaded_src);
EXPECT_EQ(loaded_text->GetConnectedOutput(
olive::TextGeneratorV3::kArgsInput, 1),
loaded_dst);
EXPECT_TRUE(olive::Node::AreLinked(loaded_src, loaded_dst));
EXPECT_TRUE(olive::Node::AreLinked(loaded_dst, loaded_src));
EXPECT_EQ(loaded_root->GetNodePositionInContext(loaded_src),
QPointF(10.0, 20.0));
EXPECT_TRUE(loaded_root->IsNodeExpandedInContext(loaded_src));
EXPECT_EQ(loaded_root->GetNodePositionInContext(loaded_dst),
QPointF(-3.5, 7.25));
EXPECT_FALSE(loaded_root->IsNodeExpandedInContext(loaded_dst));
olive::NodeFactory::Destroy();
}
+872
View File
@@ -0,0 +1,872 @@
#include <gtest/gtest.h>
#include <cstring>
#include <QCoreApplication>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QList>
#include <QSignalSpy>
#include <QTemporaryDir>
#include <QVariant>
#include "codec/conformmanager.h"
#include "config/config.h"
#include "core.h"
#include "node/color/colormanager/colormanager.h"
#include "node/generator/solid/solid.h"
#include "node/keying/chromakey/chromakey.h"
#include "node/output/viewer/viewer.h"
#include "node/project.h"
#include "olive/core/render/audioparams.h"
#include "olive/core/render/samplebuffer.h"
#include "render/audioplaybackcache.h"
#include "render/backend/dynamicrenderer.h"
#include "render/colorprocessor.h"
#include "render/diskmanager.h"
#include "render/job/colortransformjob.h"
#include "render/previewautocacher.h"
#include "render/renderer.h"
#include "render/rendermanager.h"
#include "render/texture.h"
#include "render/videoparams.h"
namespace
{
// CPU-only olive::Renderer that records the shader code it is asked to compile
// so Renderer::GetColorContext() shader generation can be verified without a
// GL/Vulkan backend.
class ShaderCaptureRenderer : public olive::Renderer {
public:
ShaderCaptureRenderer()
: create_shader_count(0)
, create_texture_count(0)
, blit_count(0)
{
}
bool Init() override
{
return true;
}
void PostDestroy() override
{
}
void PostInit() override
{
}
void ClearDestination(olive::Texture *texture, double r, double g, double b,
double a) override
{
}
QVariant CreateNativeShader(olive::ShaderCode code) override
{
create_shader_count++;
last_frag_code = code.frag_code();
last_vert_code = code.vert_code();
return QVariant(create_shader_count);
}
void DestroyNativeShader(QVariant shader) override
{
}
void UploadToTexture(const QVariant &handle, const olive::VideoParams &params,
const void *data, int linesize) override
{
}
void DownloadFromTexture(const QVariant &handle,
const olive::VideoParams &params, void *data,
int linesize) override
{
}
void Flush() override
{
}
olive::Color GetPixelFromTexture(olive::Texture *texture,
const QPointF &pt) override
{
return olive::Color();
}
int create_shader_count;
int create_texture_count;
int blit_count;
QString last_frag_code;
QString last_vert_code;
protected:
void Blit(QVariant shader, olive::AcceleratedJob &job,
olive::Texture *destination, olive::VideoParams destination_params,
bool clear_destination) override
{
blit_count++;
}
QVariant CreateNativeTexture(int width, int height, int depth,
olive::PixelFormat format, int channel_count,
const void *data, int linesize) override
{
create_texture_count++;
return QVariant(create_texture_count);
}
void DestroyNativeTexture(QVariant texture) override
{
}
void DestroyInternal() override
{
}
};
olive::ColorProcessorPtr MakeIdentityProcessor()
{
olive::ColorManager::SetUpDefaultConfig();
OCIO::MatrixTransformRcPtr transform = OCIO::MatrixTransform::Create();
transform->setDirection(OCIO::TRANSFORM_DIR_FORWARD);
return olive::ColorProcessor::Create(
olive::ColorManager::GetDefaultConfig()->getProcessor(transform));
}
bool WriteFile(const QString &path, qint64 size)
{
QFile file(path);
if (!file.open(QFile::WriteOnly)) {
return false;
}
file.write(QByteArray(static_cast<int>(size), 'x'));
file.close();
return true;
}
bool ReadBytesAt(const QString &path, qint64 offset, qint64 len, QByteArray *out)
{
QFile f(path);
if (!f.open(QFile::ReadOnly)) {
return false;
}
if (!f.seek(offset)) {
return false;
}
*out = f.read(len);
return out->size() == len;
}
float BytesToFloat(const QByteArray &bytes)
{
float v;
memcpy(&v, bytes.constData(), sizeof(v));
return v;
}
// AudioPlaybackCache always stores audio in fixed-size segments of 10 MB per
// channel (AudioPlaybackCache::kDefaultSegmentSizePerChannel).
const qint64 kSegmentSize = 10 * 1024 * 1024;
} // namespace
// A DynamicRenderer constructed with an empty backend name must report no
// backend type; the name is stored verbatim (only lowercased).
TEST(DynamicRenderer, EmptyBackendNameHasNoBackendType)
{
olive::DynamicRenderer renderer{ QString() };
EXPECT_TRUE(renderer.backend_name().isEmpty());
EXPECT_FALSE(renderer.IsOpenGL());
EXPECT_FALSE(renderer.IsVulkan());
// Without a loaded backend, context and info accessors stay at defaults
EXPECT_EQ(renderer.OpenGLContext(), nullptr);
OakRenderBackendInfo info = {};
EXPECT_FALSE(renderer.GetBackendInfo(&info));
}
// BackendFromString lowercases its input before comparing, so mixed-case
// spellings of every backend must resolve correctly.
TEST(RenderManagerBackendStrings, FromStringIsCaseInsensitive)
{
EXPECT_EQ(olive::RenderManager::BackendFromString(QStringLiteral("VULKAN")),
olive::RenderManager::kVulkan);
EXPECT_EQ(
olive::RenderManager::BackendFromString(QStringLiteral("MultiProcess")),
olive::RenderManager::kMultiProcess);
EXPECT_EQ(olive::RenderManager::BackendFromString(QStringLiteral("DUMMY")),
olive::RenderManager::kDummy);
// Unknown and empty strings fall through to OpenGL
EXPECT_EQ(olive::RenderManager::BackendFromString(QString()),
olive::RenderManager::kOpenGL);
}
// BackendToString has a default return after the switch for out-of-range enum
// values, which must be the OpenGL string.
TEST(RenderManagerBackendStrings, ToStringFallsBackToOpenGLForUnknownEnum)
{
EXPECT_EQ(olive::RenderManager::BackendToString(
static_cast<olive::RenderManager::Backend>(42)),
QStringLiteral("opengl"));
}
// A ColorTransformJob with a custom OCIO function name must have that name
// embedded in the shader generated by GetColorContext (it is passed to
// GpuShaderDesc::setFunctionName).
TEST(RendererColorContext, CustomFunctionNameIsCompiledIntoShader)
{
ShaderCaptureRenderer renderer;
olive::ColorProcessorPtr processor = MakeIdentityProcessor();
ASSERT_TRUE(processor);
olive::ColorTransformJob job;
job.SetColorProcessor(processor);
job.SetFunctionName(QStringLiteral("MyCustomOcioFunc"));
const olive::VideoParams params(32, 32, olive::PixelFormat::U8,
olive::VideoParams::kRGBAChannelCount);
renderer.BlitColorManaged(job, params);
ASSERT_EQ(renderer.create_shader_count, 1);
EXPECT_TRUE(
renderer.last_frag_code.contains(QStringLiteral("MyCustomOcioFunc")));
EXPECT_EQ(renderer.blit_count, 1);
renderer.Destroy();
}
// When the job names a custom shader source node, GetColorContext must ask the
// node for its shader code (with the OCIO stub) instead of using the built-in
// colormanage stub. ChromaKeyNode wraps the stub in its chroma-key shader.
TEST(RendererColorContext, CustomShaderSourceSuppliesFragmentCode)
{
ShaderCaptureRenderer renderer;
olive::ColorProcessorPtr processor = MakeIdentityProcessor();
ASSERT_TRUE(processor);
olive::ChromaKeyNode key_node;
olive::ColorTransformJob job;
job.SetColorProcessor(processor);
job.SetNeedsCustomShader(&key_node);
const olive::VideoParams params(32, 32, olive::PixelFormat::U8,
olive::VideoParams::kRGBAChannelCount);
renderer.BlitColorManaged(job, params);
ASSERT_EQ(renderer.create_shader_count, 1);
// A uniform name unique to chromakey.frag proves the node's code was used
EXPECT_TRUE(renderer.last_frag_code.contains(QStringLiteral("color_key")));
EXPECT_EQ(renderer.blit_count, 1);
renderer.Destroy();
}
class RenderTailAutoCacherTest : public ::testing::Test {
protected:
void SetUp() override
{
olive::ColorManager::SetUpDefaultConfig();
// Use the dummy render backend so PreviewAutoCacher can be exercised
// without initializing OpenGL/Vulkan in the unit-test process.
olive::Config::Current()[QStringLiteral("GraphicsBackend")] =
QStringLiteral("dummy");
olive::DiskManager::CreateInstance();
olive::ConformManager::CreateInstance();
olive::RenderManager::CreateInstance();
project_ = std::make_unique<olive::Project>();
project_->Initialize();
}
void TearDown() override
{
project_.reset();
olive::RenderManager::DestroyInstance();
olive::ConformManager::DestroyInstance();
olive::DiskManager::DestroyInstance();
}
olive::ViewerOutput *CreateViewerWithParams()
{
auto *viewer = new olive::ViewerOutput();
viewer->setParent(project_.get());
viewer->SetVideoParams(
olive::VideoParams(64, 64, olive::rational(1, 25),
olive::PixelFormat::U8,
olive::VideoParams::kRGBAChannelCount));
return viewer;
}
std::unique_ptr<olive::Project> project_;
};
// While renders are paused, a forced cache range must sit in the pending queue;
// unpausing dispatches it and emits StopCacheProxyTasks once the (single frame)
// range iterator is exhausted.
TEST_F(RenderTailAutoCacherTest, PausedRendersDelayForcedCacheRange)
{
olive::ViewerOutput *viewer = CreateViewerWithParams();
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks);
cacher.SetRendersPaused(true);
cacher.ForceCacheRange(
viewer, olive::TimeRange(olive::rational(0), olive::rational(1, 25)));
EXPECT_EQ(stop_spy.count(), 0);
cacher.SetRendersPaused(false);
EXPECT_GE(stop_spy.count(), 1);
// Deliver the queued RenderTicketWatcher::Finished emissions so the
// completed watchers are reaped before teardown.
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
// The thumbnail pause gates only the video-job half of TryRender, so a forced
// cache range queued while thumbnails are paused must wait for the unpause.
TEST_F(RenderTailAutoCacherTest, PausedThumbnailsDelayForcedCacheRange)
{
olive::ViewerOutput *viewer = CreateViewerWithParams();
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
QSignalSpy stop_spy(&cacher, &olive::PreviewAutoCacher::StopCacheProxyTasks);
cacher.SetThumbnailsPaused(true);
cacher.ForceCacheRange(
viewer, olive::TimeRange(olive::rational(0), olive::rational(1, 25)));
EXPECT_EQ(stop_spy.count(), 0);
cacher.SetThumbnailsPaused(false);
EXPECT_GE(stop_spy.count(), 1);
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
// With a project set, GetSingleFrame resolves the node through the ProjectCopier
// and dispatches a real render ticket. With the dummy backend the underlying
// ticket finishes without a result, and the passthrough ticket must be finished
// once the watcher signals completion.
TEST_F(RenderTailAutoCacherTest, GetSingleFrameDispatchesThroughProjectCopy)
{
olive::ViewerOutput *viewer = CreateViewerWithParams();
auto *solid = new olive::SolidGenerator();
solid->setParent(project_.get());
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
olive::RenderTicketPtr ticket =
cacher.GetSingleFrame(solid, viewer, olive::rational(0));
ASSERT_NE(ticket, nullptr);
EXPECT_TRUE(ticket->IsRunning());
// The dummy backend has no render threads, so the dispatched ticket can
// only be finished through the clear path (covered in detail by the
// ClearSingleFrameRenders tests below).
cacher.ClearSingleFrameRenders();
EXPECT_EQ(ticket->GetFinishCount(), 1);
EXPECT_FALSE(ticket->IsRunning());
EXPECT_FALSE(ticket->HasResult());
cacher.SetProject(nullptr);
}
// ClearSingleFrameRenders must cancel every dispatched (but no longer running)
// single-frame passthrough: the ticket is finished without a result and the
// watcher is reaped synchronously through VideoRendered.
TEST_F(RenderTailAutoCacherTest, ClearSingleFrameRendersFinishesDispatchedTicket)
{
olive::ViewerOutput *viewer = CreateViewerWithParams();
auto *solid = new olive::SolidGenerator();
solid->setParent(project_.get());
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
olive::RenderTicketPtr ticket =
cacher.GetSingleFrame(solid, viewer, olive::rational(0));
ASSERT_NE(ticket, nullptr);
ASSERT_TRUE(ticket->IsRunning());
cacher.ClearSingleFrameRenders();
EXPECT_EQ(ticket->GetFinishCount(), 1);
EXPECT_FALSE(ticket->IsRunning());
EXPECT_FALSE(ticket->HasResult());
// Flush the stale queued watcher notification (its receiver is gone now).
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
// ClearSingleFrameRendersThatArentRunning follows the same path for the dummy
// backend, whose tickets are never running by the time they can be cleared.
TEST_F(RenderTailAutoCacherTest,
ClearSingleFrameRendersThatArentRunningFinishesDispatchedTicket)
{
olive::ViewerOutput *viewer = CreateViewerWithParams();
auto *solid = new olive::SolidGenerator();
solid->setParent(project_.get());
olive::PreviewAutoCacher cacher;
cacher.SetProject(project_.get());
olive::RenderTicketPtr ticket =
cacher.GetSingleFrame(solid, viewer, olive::rational(0));
ASSERT_NE(ticket, nullptr);
ASSERT_TRUE(ticket->IsRunning());
cacher.ClearSingleFrameRendersThatArentRunning();
EXPECT_EQ(ticket->GetFinishCount(), 1);
EXPECT_FALSE(ticket->IsRunning());
EXPECT_FALSE(ticket->HasResult());
QCoreApplication::processEvents();
cacher.SetProject(nullptr);
}
class RenderTailDiskCacheTest : public ::testing::Test {
protected:
void SetUp() override
{
if (!temp_dir_.isValid()) {
GTEST_FAIL() << "Failed to create temporary directory";
}
if (!olive::Core::instance()) {
// Leaked intentionally: matches render_diskcache_test, Core is
// process-wide and eviction paths call Core::WarnCacheFull().
new olive::Core(olive::Core::CoreParams());
}
olive::DiskManager::CreateInstance();
}
void TearDown() override
{
olive::DiskManager::DestroyInstance();
}
QString MakeSubDir(const QString &name) const
{
QDir root(temp_dir_.path());
if (!root.mkpath(name)) {
return QString();
}
return root.filePath(name);
}
QTemporaryDir temp_dir_;
};
// Moving a folder to a new path must broadcast DeletedFrame for every tracked
// file (without deleting anything on disk) and reset the folder state to
// defaults before loading the new path's index.
TEST_F(RenderTailDiskCacheTest, SetPathEmitsDeletedFramesAndResetsState)
{
const QString sub1 = MakeSubDir(QStringLiteral("move_from"));
const QString sub2 = MakeSubDir(QStringLiteral("move_to"));
ASSERT_FALSE(sub1.isEmpty());
ASSERT_FALSE(sub2.isEmpty());
olive::DiskCacheFolder folder(sub1);
folder.SetLimit(12345);
const QString fn = QDir(sub1).filePath(QStringLiteral("frame"));
ASSERT_TRUE(WriteFile(fn, 64));
folder.CreatedFile(fn);
QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame);
folder.SetPath(sub2);
EXPECT_EQ(folder.GetPath(), sub2);
EXPECT_EQ(folder.GetLimit(), 21474836480LL); // back to the 20 GB default
EXPECT_FALSE(folder.GetClearOnClose());
ASSERT_EQ(spy.count(), 1);
const QList<QVariant> args = spy.takeFirst();
EXPECT_EQ(args.at(0).toString(), sub1);
EXPECT_EQ(args.at(1).toString(), fn);
// The file itself is untouched, but it is no longer tracked
EXPECT_TRUE(QFileInfo::exists(fn));
EXPECT_FALSE(folder.DeleteSpecificFile(fn));
}
// When the persisted index references files that have since been deleted
// externally, those entries must be skipped on load while surviving files are
// still picked up.
TEST_F(RenderTailDiskCacheTest, PersistedIndexSkipsFilesThatNoLongerExist)
{
const QString sub = MakeSubDir(QStringLiteral("index_skip"));
ASSERT_FALSE(sub.isEmpty());
const QString keep = QDir(sub).filePath(QStringLiteral("keep"));
const QString gone = QDir(sub).filePath(QStringLiteral("gone"));
ASSERT_TRUE(WriteFile(keep, 32));
ASSERT_TRUE(WriteFile(gone, 32));
{
olive::DiskCacheFolder folder(sub);
folder.CreatedFile(keep);
folder.CreatedFile(gone);
// Destruction writes the index file into the cache folder
}
ASSERT_TRUE(QFile::remove(gone));
{
olive::DiskCacheFolder reopened(sub);
// The missing file was not re-registered, the surviving one was
EXPECT_TRUE(reopened.DeleteSpecificFile(keep));
EXPECT_FALSE(reopened.DeleteSpecificFile(gone));
EXPECT_FALSE(QFileInfo::exists(keep));
}
}
// The clear-on-close flag is serialized into the index along with the limit,
// so a folder reopened after closing with the flag set must restore it.
TEST_F(RenderTailDiskCacheTest, ClearOnCloseFlagPersistsAcrossInstances)
{
const QString sub = MakeSubDir(QStringLiteral("persist_clear_flag"));
ASSERT_FALSE(sub.isEmpty());
const QString fn = QDir(sub).filePath(QStringLiteral("frame"));
ASSERT_TRUE(WriteFile(fn, 32));
{
olive::DiskCacheFolder folder(sub);
folder.SetClearOnClose(true);
folder.CreatedFile(fn);
// Destruction clears the cache and saves the flag into the index
}
ASSERT_FALSE(QFileInfo::exists(fn));
{
olive::DiskCacheFolder reopened(sub);
EXPECT_TRUE(reopened.GetClearOnClose());
// The cleared entry must not come back through the index either
EXPECT_FALSE(reopened.DeleteSpecificFile(fn));
}
}
// Registering a file that does not exist on disk tracks it with size zero;
// deleting it again succeeds because a missing file counts as deleted.
TEST_F(RenderTailDiskCacheTest, CreatedFileForMissingFileIsTrackedAsZeroSize)
{
const QString sub = MakeSubDir(QStringLiteral("zero_size"));
ASSERT_FALSE(sub.isEmpty());
olive::DiskCacheFolder folder(sub);
const QString ghost = QDir(sub).filePath(QStringLiteral("ghost"));
ASSERT_FALSE(QFileInfo::exists(ghost));
folder.CreatedFile(ghost);
QSignalSpy spy(&folder, &olive::DiskCacheFolder::DeletedFrame);
EXPECT_TRUE(folder.DeleteSpecificFile(ghost));
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().at(0).toString(), sub);
EXPECT_EQ(spy.first().at(1).toString(), ghost);
}
// DiskManager::Accessed/CreatedFile forward to the matching folder and
// DeleteSpecificFile broadcasts to every open folder, re-emitting the folder's
// DeletedFrame signal as its own.
TEST_F(RenderTailDiskCacheTest,
DiskManagerAccessedAndDeleteSpecificFileForwardToFolder)
{
olive::DiskManager *dm = olive::DiskManager::instance();
ASSERT_NE(dm, nullptr);
const QString sub = MakeSubDir(QStringLiteral("forwarding"));
ASSERT_FALSE(sub.isEmpty());
const QString fn = QDir(sub).filePath(QStringLiteral("frame"));
ASSERT_TRUE(WriteFile(fn, 32));
dm->CreatedFile(sub, fn);
dm->Accessed(sub, fn);
ASSERT_TRUE(QFileInfo::exists(fn));
QSignalSpy spy(dm, &olive::DiskManager::DeletedFrame);
dm->DeleteSpecificFile(fn);
EXPECT_FALSE(QFileInfo::exists(fn));
ASSERT_EQ(spy.count(), 1);
EXPECT_EQ(spy.first().at(0).toString(), sub);
EXPECT_EQ(spy.first().at(1).toString(), fn);
}
// The static path helpers must return distinct, non-empty locations; the config
// file name is part of the on-disk format.
TEST_F(RenderTailDiskCacheTest, DefaultDiskCachePathsAreNonEmptyAndDistinct)
{
const QString config_file =
olive::DiskManager::GetDefaultDiskCacheConfigFile();
const QString cache_path = olive::DiskManager::GetDefaultDiskCachePath();
EXPECT_FALSE(config_file.isEmpty());
EXPECT_FALSE(cache_path.isEmpty());
EXPECT_NE(config_file, cache_path);
EXPECT_TRUE(config_file.endsWith(QStringLiteral("defaultdiskcache")));
}
class RenderTailAudioCacheTest : public ::testing::Test {
protected:
void SetUp() override
{
if (!temp_dir_.isValid()) {
GTEST_FAIL() << "Failed to create temporary directory";
}
if (!olive::Core::instance()) {
new olive::Core(olive::Core::CoreParams()); // intentionally leaked
}
olive::DiskManager::CreateInstance();
// Point the project cache at a folder alongside the (unsaved) project
// file so every cache write stays inside the temporary directory.
olive::ColorManager::SetUpDefaultConfig();
project_ = std::make_unique<olive::Project>();
project_->Initialize();
project_->set_filename(
QDir(temp_dir_.path()).filePath(QStringLiteral("test.ove")));
project_->SetCacheLocationSetting(
olive::Project::kCacheStoreAlongsideProject);
}
void TearDown() override
{
project_.reset();
olive::DiskManager::DestroyInstance();
}
static olive::core::AudioParams MakeParams()
{
return olive::core::AudioParams(48000,
olive::core::kChannelLayoutStereo,
olive::core::SampleFormat::F32P);
}
static void FillBuffer(olive::core::SampleBuffer *buf, float ch0, float ch1)
{
for (size_t i = 0; i < buf->sample_count(); i++) {
buf->data(0)[i] = ch0;
buf->data(1)[i] = ch1;
}
}
QTemporaryDir temp_dir_;
std::unique_ptr<olive::Project> project_;
};
// SetParameters stores the audio params; setting the same value twice early-outs
// and leaves them untouched.
TEST_F(RenderTailAudioCacheTest, SetParametersRoundTrip)
{
olive::AudioPlaybackCache cache(project_.get());
EXPECT_EQ(cache.GetParameters().channel_count(), 0);
const olive::core::AudioParams params = MakeParams();
cache.SetParameters(params);
EXPECT_EQ(cache.GetParameters(), params);
cache.SetParameters(params);
EXPECT_EQ(cache.GetParameters(), params);
const olive::core::AudioParams other(44100,
olive::core::kChannelLayoutMono,
olive::core::SampleFormat::F32P);
cache.SetParameters(other);
EXPECT_EQ(cache.GetParameters().sample_rate(), 44100);
EXPECT_EQ(cache.GetParameters().channel_count(), 1);
}
// WritePCM writes one segment file per channel, zero-padded to the full segment
// size, and validates exactly the written range.
TEST_F(RenderTailAudioCacheTest, WritePcmWritesSegmentFilesAndValidatesRange)
{
olive::AudioPlaybackCache cache(project_.get());
cache.SetParameters(MakeParams());
const olive::TimeRange range(olive::rational(0), olive::rational(1, 10));
olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10));
ASSERT_TRUE(buf.is_allocated());
FillBuffer(&buf, 0.5f, 0.25f);
cache.WritePCM(range, { range }, buf);
EXPECT_TRUE(cache.HasValidatedRanges());
EXPECT_FALSE(cache.HasInvalidatedRanges(range));
// 4800 samples of 4-byte floats per channel
const qint64 data_bytes = 19200;
const QDir seg_dir = cache.GetThisCacheDirectory();
const QString ch0 = seg_dir.filePath(QStringLiteral("0.0"));
const QString ch1 = seg_dir.filePath(QStringLiteral("0.1"));
ASSERT_TRUE(QFileInfo::exists(ch0));
ASSERT_TRUE(QFileInfo::exists(ch1));
// The buffer covered the whole range, so no padding is needed and the
// segment contains exactly the written data
EXPECT_EQ(QFileInfo(ch0).size(), data_bytes);
EXPECT_EQ(QFileInfo(ch1).size(), data_bytes);
QByteArray bytes;
ASSERT_TRUE(ReadBytesAt(ch0, 0, 4, &bytes));
EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.5f);
ASSERT_TRUE(ReadBytesAt(ch1, 0, 4, &bytes));
EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.25f);
}
// A write that does not start at zero must seek into the segment, leaving the
// preceding bytes as silence.
TEST_F(RenderTailAudioCacheTest, WritePcmAtNonZeroStartWritesAtByteOffset)
{
olive::AudioPlaybackCache cache(project_.get());
cache.SetParameters(MakeParams());
const olive::TimeRange range(olive::rational(1, 10), olive::rational(1, 5));
olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10));
ASSERT_TRUE(buf.is_allocated());
FillBuffer(&buf, 0.75f, 0.75f);
cache.WritePCM(range, { range }, buf);
EXPECT_FALSE(cache.HasInvalidatedRanges(range));
const qint64 data_bytes = 19200;
const QString ch0 =
cache.GetThisCacheDirectory().filePath(QStringLiteral("0.0"));
ASSERT_TRUE(QFileInfo::exists(ch0));
// The file extends exactly to the end of the written range
EXPECT_EQ(QFileInfo(ch0).size(), 2 * data_bytes);
// The first range was never written, so it reads back as silence
QByteArray bytes;
ASSERT_TRUE(ReadBytesAt(ch0, 0, 4, &bytes));
EXPECT_EQ(bytes, QByteArray(4, '\0'));
// The new data starts exactly at its byte offset
ASSERT_TRUE(ReadBytesAt(ch0, data_bytes, 4, &bytes));
EXPECT_FLOAT_EQ(BytesToFloat(bytes), 0.75f);
}
// Only the listed valid ranges are validated, even when the sample buffer
// covers the whole render range.
TEST_F(RenderTailAudioCacheTest, WritePcmWithPartialValidRangesValidatesOnlyThose)
{
olive::AudioPlaybackCache cache(project_.get());
cache.SetParameters(MakeParams());
const olive::TimeRange range(olive::rational(0), olive::rational(1, 5));
const olive::TimeRange first_half(olive::rational(0), olive::rational(1, 10));
olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 5));
ASSERT_TRUE(buf.is_allocated());
FillBuffer(&buf, 0.5f, 0.5f);
cache.WritePCM(range, { first_half }, buf);
EXPECT_FALSE(cache.HasInvalidatedRanges(first_half));
EXPECT_TRUE(cache.HasInvalidatedRanges(range));
}
// An empty valid-range list writes no segments and validates nothing.
TEST_F(RenderTailAudioCacheTest, WritePcmWithNoValidRangesWritesNothing)
{
olive::AudioPlaybackCache cache(project_.get());
cache.SetParameters(MakeParams());
const olive::TimeRange range(olive::rational(0), olive::rational(1, 10));
olive::core::SampleBuffer buf(MakeParams(), olive::rational(1, 10));
ASSERT_TRUE(buf.is_allocated());
cache.WritePCM(range, olive::TimeRangeList(), buf);
EXPECT_FALSE(cache.HasValidatedRanges());
EXPECT_FALSE(QFileInfo::exists(
cache.GetThisCacheDirectory().filePath(QStringLiteral("0.0"))));
}
// A write larger than one segment must spill into the next segment file, with
// each touched segment zero-padded to its full extent.
TEST_F(RenderTailAudioCacheTest,
WritePcmSpanningSegmentBoundaryCreatesBothSegments)
{
olive::AudioPlaybackCache cache(project_.get());
cache.SetParameters(MakeParams());
// 56 seconds at 48000 Hz is 10752000 bytes per channel, just over one
// 10 MB segment.
const olive::TimeRange range(olive::rational(0), olive::rational(56));
olive::core::SampleBuffer buf(MakeParams(), olive::rational(56));
ASSERT_TRUE(buf.is_allocated());
ASSERT_EQ(buf.sample_count(), size_t(56 * 48000));
FillBuffer(&buf, 1.0f, 1.0f);
cache.WritePCM(range, { range }, buf);
EXPECT_FALSE(cache.HasInvalidatedRanges(range));
const QDir seg_dir = cache.GetThisCacheDirectory();
const QString seg0 = seg_dir.filePath(QStringLiteral("0.0"));
const QString seg1 = seg_dir.filePath(QStringLiteral("1.0"));
ASSERT_TRUE(QFileInfo::exists(seg0));
ASSERT_TRUE(QFileInfo::exists(seg1));
EXPECT_EQ(QFileInfo(seg0).size(), kSegmentSize);
// The second segment holds exactly the spillover bytes
EXPECT_EQ(QFileInfo(seg1).size(),
56 * 48000 * 4 - kSegmentSize);
// The spillover data starts at the beginning of the second segment file
QByteArray bytes;
ASSERT_TRUE(ReadBytesAt(seg1, 0, 4, &bytes));
EXPECT_FLOAT_EQ(BytesToFloat(bytes), 1.0f);
}