/* * 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 #include #include #include #include #include #include #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(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 { 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_->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( 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 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(); } QString backend_; QString demo_path_; std::unique_ptr 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")));