Files
oak-editor/tests/gtest/render_clip_buffer_hint_test.cpp
T
Mike-Solar 7832cb30b3 build: move render worker out of app/ into top-level worker/
- oak-render-worker now builds from worker/ (own CMakeLists.txt) as a
  peer of app/; RenderWorkerPool resolves the new build-tree location
- deduplicated the Linux install() rules for the worker
- worker-spawning tests resolve build/worker instead of build/app
- cd.yml: Windows staging copies the worker from its new output path
2026-07-19 22:12:29 +08:00

370 lines
11 KiB
C++

/*
* Oak Video Editor - Clip Buffer Value Hint Regression Test
* Copyright (C) 2026 Oak Team
*
* Regression test for a pre-existing black-screen bug: when a footage node
* was connected directly to a clip's buffer input (no effect node in
* between), the preview rendered black. The buffer input is declared as
* NodeValue::kNone, so the traverser had no type information when pulling a
* value from the connected node's table and fell back to the last value in
* the table. A footage pushes its video texture first and its audio samples
* last, so a video clip ended up fed with audio samples and produced no
* texture at all. The fix makes ClipBlock::GetValueHintForInput prefer the
* value type matching the clip's track.
*
* These tests drive the exact application render path:
* PreviewAutoCacher -> RenderManager -> RenderWorkerPool -> oak-render-worker.
*/
#include <gtest/gtest.h>
#include <atomic>
#include <QCoreApplication>
#include <QDir>
#include <QElapsedTimer>
#include <QFileInfo>
#include <QThread>
#include "codec/conformmanager.h"
#include "codec/frame.h"
#include "codec/proxymanager.h"
#include "config/config.h"
#include "node/block/clip/clip.h"
#include "node/color/colormanager/colormanager.h"
#include "node/effect/opacity/opacityeffect.h"
#include "node/factory.h"
#include "node/output/track/track.h"
#include "node/output/track/tracklist.h"
#include "node/output/viewer/viewer.h"
#include "node/project.h"
#include "node/project/footage/footage.h"
#include "node/project/sequence/sequence.h"
#include "node/project/serializer/serializer.h"
#include "olive/core/render/audioparams.h"
#include "olive/core/render/sampleformat.h"
#include "render/diskmanager.h"
#include "render/framemanager.h"
#include "render/previewautocacher.h"
#include "render/rendermanager.h"
#include "render/renderticket.h"
#include "task/taskmanager.h"
#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
#include "render/backend/dynamicrenderer.h"
#include "render/backend/renderbackend_c.h"
#endif
using namespace olive;
namespace
{
QString demo_video_path()
{
return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/demo.mp4"));
}
QString worker_binary_path()
{
// The test binary lives in cmake-build-debug/tests/gtest; the worker is in
// cmake-build-debug/worker.
QDir dir(QCoreApplication::applicationDirPath());
dir.cdUp(); // tests/gtest -> tests
dir.cdUp(); // tests -> build dir
dir.cd(QStringLiteral("worker"));
#if defined(_WIN32)
return dir.filePath(QStringLiteral("oak-render-worker.exe"));
#else
return dir.filePath(QStringLiteral("oak-render-worker"));
#endif
}
bool is_render_backend_available(const QString &backend)
{
#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
olive::DynamicRenderer renderer(backend);
if (!renderer.load()) {
return false;
}
OakRenderBackendInfo info = {};
if (!renderer.get_backend_info(&info)) {
return false;
}
if (backend == QStringLiteral("vulkan") &&
info.kind != oak_render_backend_vulkan) {
return false;
}
if (backend == QStringLiteral("opengl") &&
info.kind != oak_render_backend_opengl) {
return false;
}
return renderer.init();
#else
Q_UNUSED(backend)
return false;
#endif
}
// Returns the number of non-zero bytes sampled from the frame buffer, or -1
// when the frame is invalid.
int count_non_zero_bytes(const FramePtr &frame)
{
if (!frame || !frame->is_allocated()) {
return -1;
}
const auto *data = reinterpret_cast<const uint8_t *>(frame->const_data());
const int size = frame->allocated_size();
int nonzero = 0;
// Sample across the buffer to keep the check fast.
const int step = qMax(1, size / 4096);
for (int i = 0; i < size; i += step) {
if (data[i] != 0) {
nonzero++;
}
}
return nonzero;
}
} // namespace
class RenderClipBufferHintTest
: public ::testing::Test,
public ::testing::WithParamInterface<QString> {
protected:
static void SetUpTestSuite()
{
// Mirror Core::Start()'s singleton initialization order (RenderManager
// is created per-test instead, so that each backend gets a fresh one).
NodeFactory::initialize();
ColorManager::set_up_default_config();
TaskManager::create_instance();
ConformManager::create_instance();
ProxyManager::create_instance();
FrameManager::create_instance();
ProjectSerializer::initialize();
DiskManager::create_instance();
// Point the worker pool at the built worker binary.
const QString worker = worker_binary_path();
if (QFileInfo::exists(worker)) {
qputenv("OAK_RENDER_WORKER", QFile::encodeName(worker));
}
}
static void TearDownTestSuite()
{
DiskManager::destroy_instance();
ProjectSerializer::destroy();
FrameManager::destroy_instance();
ProxyManager::destroy_instance();
ConformManager::destroy_instance();
TaskManager::destroy_instance();
NodeFactory::destroy();
}
void SetUp() override
{
backend_ = GetParam();
if (!is_render_backend_available(backend_)) {
GTEST_SKIP() << "Render backend is not available: "
<< backend_.toStdString();
}
const QString worker = worker_binary_path();
if (!QFileInfo::exists(worker)) {
GTEST_SKIP() << "worker binary not found at "
<< worker.toStdString();
}
demo_path_ = demo_video_path();
ASSERT_TRUE(QFileInfo::exists(demo_path_));
Config::current()[QStringLiteral("GraphicsBackend")] = backend_;
project_ = std::make_unique<Project>();
project_->initialize();
footage_ = new Footage(demo_path_);
footage_->setParent(project_.get());
ASSERT_TRUE(footage_->is_valid())
<< "Footage failed to probe " << demo_path_.toStdString();
// The bug requires the footage to provide both a video and an audio
// stream, so that the video texture is not the last value in the
// footage's table.
ASSERT_GE(footage_->get_video_stream_count(), 1);
ASSERT_GE(footage_->get_audio_stream_count(), 1)
<< "Test footage must contain an audio stream";
RenderManager::create_instance();
RenderManager::instance()->get_cacher()->set_project(project_.get());
}
void TearDown() override
{
// May be null when SetUp() skipped before creating the instance.
if (RenderManager::instance()) {
RenderManager::instance()->get_cacher()->set_project(nullptr);
RenderManager::destroy_instance();
}
project_.reset();
}
// Builds sequence <- track <- clip <- (optional effect) <- footage and
// returns the clip. When insert_effect is false the footage is connected
// directly to the clip's buffer input, which is the black-screen case.
ClipBlock *build_video_clip_chain(bool insert_effect)
{
sequence_ = new Sequence();
sequence_->setParent(project_.get());
sequence_->set_video_params(VideoParams(
1920, 1080, Rational(25),
static_cast<PixelFormat::Format>(
Config::current()[QStringLiteral("OfflinePixelFormat")]
.toInt()),
VideoParams::k_internal_channel_count, Rational(1),
VideoParams::k_interlace_none, 1));
sequence_->set_audio_params(olive::core::AudioParams(
48000, olive::core::k_channel_layout_stereo,
olive::core::SampleFormat::f32_p));
Track *track = new Track();
track->setParent(project_.get());
video_track_ = track;
ClipBlock *clip = new ClipBlock();
clip->setParent(project_.get());
clip->set_length_and_media_out(footage_->get_length());
Node *buffer_source = footage_;
if (insert_effect) {
OpacityEffect *opacity = new OpacityEffect();
opacity->setParent(project_.get());
Node::connect_edge(footage_,
NodeInput(opacity, OpacityEffect::k_texture_input));
buffer_source = opacity;
}
Node::connect_edge(buffer_source,
NodeInput(clip, ClipBlock::k_buffer_in));
track->append_block(clip);
// Wire the track into the sequence's video track list (this is what
// assigns the track its type) and into the sequence's texture output.
TrackList *track_list = sequence_->track_list(Track::k_video);
track_list->array_append();
Node::connect_edge(
track, track_list->track_input(track_list->array_size() - 1));
Node::connect_edge(track,
NodeInput(sequence_, ViewerOutput::k_texture_input));
return clip;
}
// Renders one frame through the application's preview path and returns the
// resulting CPU frame (nullptr on failure/timeout).
FramePtr render_one_frame(ViewerOutput *viewer, const Rational &time)
{
RenderTicketPtr ticket =
RenderManager::instance()->get_cacher()->get_single_frame(viewer, time,
false);
if (!ticket) {
return nullptr;
}
std::atomic<bool> finished{ false };
QObject::connect(ticket.get(), &RenderTicket::finished,
[&finished]() { finished = true; });
QElapsedTimer timer;
timer.start();
while (!finished.load() && !timer.hasExpired(60000)) {
QCoreApplication::processEvents(QEventLoop::AllEvents, 20);
QThread::msleep(5);
}
if (!finished.load() || !ticket->has_result()) {
return nullptr;
}
return ticket->get().value<FramePtr>();
}
QString backend_;
QString demo_path_;
std::unique_ptr<Project> project_;
Footage *footage_ = nullptr;
Sequence *sequence_ = nullptr;
Track *video_track_ = nullptr;
};
// Footage connected directly to a video clip's buffer input must not produce
// a black frame. Before the fix, the clip pulled the footage's audio samples
// (the last value in the footage's table) instead of its video texture.
TEST_P(RenderClipBufferHintTest, DirectFootageToVideoClipNotBlack)
{
build_video_clip_chain(false);
for (double t : { 0.0, 1.0 }) {
FramePtr frame = render_one_frame(sequence_, Rational::from_double(t));
ASSERT_TRUE(frame != nullptr)
<< "Direct footage->clip render produced no frame at t=" << t;
ASSERT_TRUE(frame->is_allocated());
EXPECT_GT(count_non_zero_bytes(frame), 0)
<< "Direct footage->clip render is BLACK at t=" << t
<< " (all sampled bytes are zero)";
}
}
// Control case: an effect node between the footage and the clip always worked,
// because the effect's table contains only the passed-through texture.
TEST_P(RenderClipBufferHintTest, IndirectFootageToVideoClipNotBlack)
{
build_video_clip_chain(true);
for (double t : { 0.0, 1.0 }) {
FramePtr frame = render_one_frame(sequence_, Rational::from_double(t));
ASSERT_TRUE(frame != nullptr)
<< "Indirect footage->opacity->clip render produced no frame at t="
<< t;
ASSERT_TRUE(frame->is_allocated());
EXPECT_GT(count_non_zero_bytes(frame), 0)
<< "Indirect footage->opacity->clip render is BLACK at t=" << t
<< " (all sampled bytes are zero)";
}
}
// Unit-level guard: the buffer input's value hint must follow the clip's
// track type so the traverser pulls the right value from a multi-stream
// source.
TEST_P(RenderClipBufferHintTest, BufferHintFollowsTrackType)
{
ClipBlock *clip = build_video_clip_chain(false);
Node::ValueHint video_hint =
clip->get_value_hint_for_input(ClipBlock::k_buffer_in);
ASSERT_FALSE(video_hint.types().isEmpty());
EXPECT_TRUE(video_hint.types().contains(NodeValue::k_texture));
// Move the clip onto an audio track: the hint must prefer samples.
video_track_->ripple_remove_block(clip);
Track *audio_track = new Track();
audio_track->setParent(project_.get());
audio_track->set_type(Track::k_audio);
audio_track->append_block(clip);
Node::ValueHint audio_hint =
clip->get_value_hint_for_input(ClipBlock::k_buffer_in);
ASSERT_FALSE(audio_hint.types().isEmpty());
EXPECT_TRUE(audio_hint.types().contains(NodeValue::k_samples));
}
INSTANTIATE_TEST_SUITE_P(Backends, RenderClipBufferHintTest,
::testing::Values(QStringLiteral("opengl"),
QStringLiteral("vulkan")));