Files
oak-editor/tests/gtest/render_clip_buffer_hint_test.cpp
T
Mike-Solar c203ce1dbf fix: black preview when footage connects directly to a clip
ClipBlock::kBufferIn is declared as NodeValue::kNone with no value
hint, so when a footage node is connected straight to a clip's buffer
input (no effect node in between), the traverser has no type to look
up in the footage's value table and falls back to its last entry. A
footage pushes its video texture first and its audio samples last, so
a video clip ended up fed with audio samples, produced no texture and
the preview went silently black. With any node in between, the table
only carries the passthrough texture, which is why the bug only showed
on direct connections.

Make Node::GetValueHintForInput virtual and override it in ClipBlock
to prefer the value type matching the clip's track (kTexture on video
tracks, kSamples on audio tracks).

Regression tests cover the exact application render path
(PreviewAutoCacher -> RenderManager -> RenderWorkerPool ->
oak-render-worker) for both the direct and indirect cases, the hint
following the track type, and viewer display widgets. The direct-case
test reproduces the black frame without the fix and passes with it.
Full suite: 584 passed.
2026-07-16 01:06:24 +08:00

367 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 DemoVideoPath()
{
return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/demo.mp4"));
}
QString WorkerBinaryPath()
{
// The test binary lives in cmake-build-debug/tests/gtest; the worker is in
// cmake-build-debug/app.
QDir dir(QCoreApplication::applicationDirPath());
dir.cdUp(); // tests/gtest -> tests
dir.cdUp(); // tests -> build dir
dir.cd(QStringLiteral("app"));
#if defined(_WIN32)
return dir.filePath(QStringLiteral("oak-render-worker.exe"));
#else
return dir.filePath(QStringLiteral("oak-render-worker"));
#endif
}
bool IsRenderBackendAvailable(const QString &backend)
{
#ifdef OAK_ENABLE_DYNAMIC_RENDER_BACKEND
olive::DynamicRenderer renderer(backend);
if (!renderer.Load()) {
return false;
}
OakRenderBackendInfo info = {};
if (!renderer.GetBackendInfo(&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 CountNonZeroBytes(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::SetUpDefaultConfig();
TaskManager::CreateInstance();
ConformManager::CreateInstance();
ProxyManager::CreateInstance();
FrameManager::CreateInstance();
ProjectSerializer::Initialize();
DiskManager::CreateInstance();
// Point the worker pool at the built worker binary.
const QString worker = WorkerBinaryPath();
if (QFileInfo::exists(worker)) {
qputenv("OAK_RENDER_WORKER", QFile::encodeName(worker));
}
}
static void TearDownTestSuite()
{
DiskManager::DestroyInstance();
ProjectSerializer::Destroy();
FrameManager::DestroyInstance();
ProxyManager::DestroyInstance();
ConformManager::DestroyInstance();
TaskManager::DestroyInstance();
NodeFactory::Destroy();
}
void SetUp() override
{
backend_ = GetParam();
if (!IsRenderBackendAvailable(backend_)) {
GTEST_SKIP() << "Render backend is not available: "
<< backend_.toStdString();
}
const QString worker = WorkerBinaryPath();
if (!QFileInfo::exists(worker)) {
GTEST_SKIP() << "worker binary not found at "
<< worker.toStdString();
}
demo_path_ = DemoVideoPath();
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_->IsValid())
<< "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_->GetVideoStreamCount(), 1);
ASSERT_GE(footage_->GetAudioStreamCount(), 1)
<< "Test footage must contain an audio stream";
RenderManager::CreateInstance();
RenderManager::instance()->GetCacher()->SetProject(project_.get());
}
void TearDown() override
{
RenderManager::instance()->GetCacher()->SetProject(nullptr);
RenderManager::DestroyInstance();
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 *BuildVideoClipChain(bool insert_effect)
{
sequence_ = new Sequence();
sequence_->setParent(project_.get());
sequence_->SetVideoParams(VideoParams(
1920, 1080, rational(25),
static_cast<PixelFormat::Format>(
Config::Current()[QStringLiteral("OfflinePixelFormat")]
.toInt()),
VideoParams::kInternalChannelCount, rational(1),
VideoParams::kInterlaceNone, 1));
sequence_->SetAudioParams(olive::core::AudioParams(
48000, olive::core::kChannelLayoutStereo,
olive::core::SampleFormat::F32P));
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_->GetLength());
Node *buffer_source = footage_;
if (insert_effect) {
OpacityEffect *opacity = new OpacityEffect();
opacity->setParent(project_.get());
Node::ConnectEdge(footage_,
NodeInput(opacity, OpacityEffect::kTextureInput));
buffer_source = opacity;
}
Node::ConnectEdge(buffer_source,
NodeInput(clip, ClipBlock::kBufferIn));
track->AppendBlock(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::kVideo);
track_list->ArrayAppend();
Node::ConnectEdge(
track, track_list->track_input(track_list->ArraySize() - 1));
Node::ConnectEdge(track,
NodeInput(sequence_, ViewerOutput::kTextureInput));
return clip;
}
// Renders one frame through the application's preview path and returns the
// resulting CPU frame (nullptr on failure/timeout).
FramePtr RenderOneFrame(ViewerOutput *viewer, const rational &time)
{
RenderTicketPtr ticket =
RenderManager::instance()->GetCacher()->GetSingleFrame(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->HasResult()) {
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)
{
BuildVideoClipChain(false);
for (double t : { 0.0, 1.0 }) {
FramePtr frame = RenderOneFrame(sequence_, rational::fromDouble(t));
ASSERT_TRUE(frame != nullptr)
<< "Direct footage->clip render produced no frame at t=" << t;
ASSERT_TRUE(frame->is_allocated());
EXPECT_GT(CountNonZeroBytes(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)
{
BuildVideoClipChain(true);
for (double t : { 0.0, 1.0 }) {
FramePtr frame = RenderOneFrame(sequence_, rational::fromDouble(t));
ASSERT_TRUE(frame != nullptr)
<< "Indirect footage->opacity->clip render produced no frame at t="
<< t;
ASSERT_TRUE(frame->is_allocated());
EXPECT_GT(CountNonZeroBytes(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 = BuildVideoClipChain(false);
Node::ValueHint video_hint =
clip->GetValueHintForInput(ClipBlock::kBufferIn);
ASSERT_FALSE(video_hint.types().isEmpty());
EXPECT_TRUE(video_hint.types().contains(NodeValue::kTexture));
// Move the clip onto an audio track: the hint must prefer samples.
video_track_->RippleRemoveBlock(clip);
Track *audio_track = new Track();
audio_track->setParent(project_.get());
audio_track->set_type(Track::kAudio);
audio_track->AppendBlock(clip);
Node::ValueHint audio_hint =
clip->GetValueHintForInput(ClipBlock::kBufferIn);
ASSERT_FALSE(audio_hint.types().isEmpty());
EXPECT_TRUE(audio_hint.types().contains(NodeValue::kSamples));
}
INSTANTIATE_TEST_SUITE_P(Backends, RenderClipBufferHintTest,
::testing::Values(QStringLiteral("opengl"),
QStringLiteral("vulkan")));