diff --git a/app/node/block/clip/clip.cpp b/app/node/block/clip/clip.cpp index 46dab9efe..be13c6692 100644 --- a/app/node/block/clip/clip.cpp +++ b/app/node/block/clip/clip.cpp @@ -141,6 +141,30 @@ rational ClipBlock::media_in() const return GetStandardValue(kMediaInInput).value(); } +Node::ValueHint ClipBlock::GetValueHintForInput(const QString &input, + int element) const +{ + if (input == kBufferIn) { + // The buffer input takes whatever the connected node provides, so it + // is declared as kNone and carries no stored hint. When the connected + // node pushes more than one value type (a footage pushes both a + // kTexture job and a kSamples job), a typeless lookup falls back to + // the last value in the table, which may feed audio samples into a + // video clip and produce a black frame. Prefer the value type that + // matches this clip's track. + switch (GetTrackType()) { + case Track::kVideo: + return ValueHint(QVector{ NodeValue::kTexture }); + case Track::kAudio: + return ValueHint(QVector{ NodeValue::kSamples }); + default: + break; + } + } + + return super::GetValueHintForInput(input, element); +} + void ClipBlock::set_media_in(const rational &media_in) { SetStandardValue(kMediaInInput, QVariant::fromValue(media_in)); diff --git a/app/node/block/clip/clip.h b/app/node/block/clip/clip.h index f3f6f3d04..3ab5a50ac 100644 --- a/app/node/block/clip/clip.h +++ b/app/node/block/clip/clip.h @@ -59,6 +59,9 @@ public: } } + virtual Node::ValueHint + GetValueHintForInput(const QString &input, int element = -1) const override; + rational media_in() const; void set_media_in(const rational &media_in); diff --git a/app/node/node.h b/app/node/node.h index 1d8563a3d..1b15afdc5 100644 --- a/app/node/node.h +++ b/app/node/node.h @@ -802,7 +802,8 @@ public: return value_hints_; } - ValueHint GetValueHintForInput(const QString &input, int element = -1) const + virtual ValueHint GetValueHintForInput(const QString &input, + int element = -1) const { return value_hints_.value({ input, element }); } diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt index c946f54dc..5063838cc 100644 --- a/tests/gtest/CMakeLists.txt +++ b/tests/gtest/CMakeLists.txt @@ -40,6 +40,9 @@ add_executable(olive-gtest render_ipc_test.cpp render_ticket_test.cpp render_worker_footage_test.cpp + render_direct_connection_test.cpp + render_clip_buffer_hint_test.cpp + viewer_display_repro_test.cpp project_serializer_test.cpp proxy_manager_test.cpp timeline_marker_test.cpp diff --git a/tests/gtest/render_clip_buffer_hint_test.cpp b/tests/gtest/render_clip_buffer_hint_test.cpp new file mode 100644 index 000000000..f3f7cdce8 --- /dev/null +++ b/tests/gtest/render_clip_buffer_hint_test.cpp @@ -0,0 +1,366 @@ +/* + * 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"))); diff --git a/tests/gtest/render_direct_connection_test.cpp b/tests/gtest/render_direct_connection_test.cpp new file mode 100644 index 000000000..32dc9e744 --- /dev/null +++ b/tests/gtest/render_direct_connection_test.cpp @@ -0,0 +1,365 @@ +/* + * Oak Video Editor - Direct-Connection Preview Black Screen Regression Test + * Copyright (C) 2026 Oak Team + * + * Reproduces the pre-existing bug where the preview is black when a footage + * node is connected directly to a viewer output, while inserting any node in + * between renders correctly. Drives 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/color/colormanager/colormanager.h" +#include "node/distort/transform/transformdistortnode.h" +#include "node/effect/opacity/opacityeffect.h" +#include "node/factory.h" +#include "node/output/viewer/viewer.h" +#include "node/project.h" +#include "node/project/footage/footage.h" +#include "node/project/serializer/serializer.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 RenderDirectConnectionTest + : 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(); + + RenderManager::CreateInstance(); + RenderManager::instance()->GetCacher()->SetProject(project_.get()); + } + + void TearDown() override + { + RenderManager::instance()->GetCacher()->SetProject(nullptr); + RenderManager::DestroyInstance(); + project_.reset(); + } + + // Renders one frame through the application's preview path and returns the + // resulting CPU frame (nullptr on failure/timeout). + FramePtr RenderOneFrame(ViewerOutput *viewer) + { + RenderTicketPtr ticket = + RenderManager::instance()->GetCacher()->GetSingleFrame( + viewer, rational(0), 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; +}; + +// Footage previewed directly (footage node connected straight to the viewer +// output, no intermediate node) must not produce a black frame. +TEST_P(RenderDirectConnectionTest, DirectConnectionIsNotBlack) +{ + ViewerOutput *viewer = new ViewerOutput(); + viewer->setParent(project_.get()); + + // Direct connection: footage -> viewer + Node::ConnectEdge(footage_, NodeInput(viewer, ViewerOutput::kTextureInput)); + + FramePtr frame = RenderOneFrame(viewer); + ASSERT_TRUE(frame != nullptr) + << "Direct connection render produced no frame (timeout or empty " + "ticket)"; + ASSERT_TRUE(frame->is_allocated()); + + int nonzero = CountNonZeroBytes(frame); + EXPECT_GT(nonzero, 0) + << "Direct connection render is BLACK (all sampled bytes are zero)"; + + Node::DisconnectEdge(footage_, + NodeInput(viewer, ViewerOutput::kTextureInput)); +} + +// Same as above but with an effect node between footage and viewer. This is +// the control case that reportedly works. +TEST_P(RenderDirectConnectionTest, IndirectConnectionIsNotBlack) +{ + ViewerOutput *viewer = new ViewerOutput(); + viewer->setParent(project_.get()); + + OpacityEffect *opacity = new OpacityEffect(); + opacity->setParent(project_.get()); + + // Indirect connection: footage -> opacity -> viewer + Node::ConnectEdge(footage_, + NodeInput(opacity, OpacityEffect::kTextureInput)); + Node::ConnectEdge(opacity, NodeInput(viewer, ViewerOutput::kTextureInput)); + + FramePtr frame = RenderOneFrame(viewer); + ASSERT_TRUE(frame != nullptr) << "Indirect connection render produced no " + "frame (timeout or empty ticket)"; + ASSERT_TRUE(frame->is_allocated()); + + int nonzero = CountNonZeroBytes(frame); + EXPECT_GT(nonzero, 0) + << "Indirect connection render is BLACK (all sampled bytes are zero)"; +} + +INSTANTIATE_TEST_SUITE_P(Backends, RenderDirectConnectionTest, + ::testing::Values(QStringLiteral("opengl"), + QStringLiteral("vulkan"))); + +// Resolution-mismatch scenarios: footage is 1920x1080 while the viewer is +// 1280x720, forcing a rescale when the frame is downloaded in the worker. +class RenderResolutionMismatchTest : public RenderDirectConnectionTest { +protected: + ViewerOutput *CreateSmallViewer() + { + ViewerOutput *viewer = new ViewerOutput(); + viewer->setParent(project_.get()); + viewer->SetVideoParams(VideoParams( + 1280, 720, rational(24), + static_cast( + Config::Current()[QStringLiteral("OfflinePixelFormat")] + .toInt()), + VideoParams::kInternalChannelCount, rational(1), + VideoParams::kInterlaceNone, 1)); + return viewer; + } + + void ExpectFrameNotBlack(FramePtr frame, const char *what) + { + ASSERT_TRUE(frame != nullptr) + << what << " render produced no frame (timeout or empty ticket)"; + ASSERT_TRUE(frame->is_allocated()); + EXPECT_GT(CountNonZeroBytes(frame), 0) + << what << " render is BLACK (all sampled bytes are zero)"; + } +}; + +// Direct connection with mismatched resolutions: the worker must rescale the +// footage-sized texture into the viewer-sized output frame. +TEST_P(RenderResolutionMismatchTest, DirectConnectionNotBlack) +{ + ViewerOutput *viewer = CreateSmallViewer(); + Node::ConnectEdge(footage_, NodeInput(viewer, ViewerOutput::kTextureInput)); + + ExpectFrameNotBlack(RenderOneFrame(viewer), + "Direct connection (resolution mismatch)"); + + Node::DisconnectEdge(footage_, + NodeInput(viewer, ViewerOutput::kTextureInput)); +} + +// Opacity passes the footage-sized texture through, so the worker still has +// to rescale at download time. Control case for the direct test above. +TEST_P(RenderResolutionMismatchTest, IndirectOpacityNotBlack) +{ + ViewerOutput *viewer = CreateSmallViewer(); + + OpacityEffect *opacity = new OpacityEffect(); + opacity->setParent(project_.get()); + + Node::ConnectEdge(footage_, + NodeInput(opacity, OpacityEffect::kTextureInput)); + Node::ConnectEdge(opacity, NodeInput(viewer, ViewerOutput::kTextureInput)); + + ExpectFrameNotBlack(RenderOneFrame(viewer), + "Indirect opacity (resolution mismatch)"); +} + +// Transform with auto-scale renders at the sequence resolution, so no size +// rescale is needed at download time. This mirrors the timeline chain. +TEST_P(RenderResolutionMismatchTest, IndirectTransformNotBlack) +{ + ViewerOutput *viewer = CreateSmallViewer(); + + TransformDistortNode *transform = new TransformDistortNode(); + transform->setParent(project_.get()); + // 1 = Fit + transform->SetStandardValue(TransformDistortNode::kAutoscaleInput, 1); + + Node::ConnectEdge(footage_, + NodeInput(transform, TransformDistortNode::kTextureInput)); + Node::ConnectEdge(transform, + NodeInput(viewer, ViewerOutput::kTextureInput)); + + ExpectFrameNotBlack(RenderOneFrame(viewer), + "Indirect transform (resolution mismatch)"); +} + +INSTANTIATE_TEST_SUITE_P(Backends, RenderResolutionMismatchTest, + ::testing::Values(QStringLiteral("opengl"), + QStringLiteral("vulkan"))); diff --git a/tests/gtest/viewer_display_repro_test.cpp b/tests/gtest/viewer_display_repro_test.cpp new file mode 100644 index 000000000..9c3464ba5 --- /dev/null +++ b/tests/gtest/viewer_display_repro_test.cpp @@ -0,0 +1,360 @@ +/* + * TEMPORARY scratch test for the direct-connection black screen + * investigation. Not meant for commit. Drives the real ViewerWidget display + * path offscreen and checks the pixels the display widget actually paints. + */ + +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "audio/audiomanager.h" +#include "codec/conformmanager.h" +#include "codec/proxymanager.h" +#include "config/config.h" +#include "core.h" +#include "node/color/colormanager/colormanager.h" +#include "node/effect/opacity/opacityeffect.h" +#include "node/factory.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 "render/diskmanager.h" +#include "render/framemanager.h" +#include "render/previewautocacher.h" +#include "render/rendermanager.h" +#include "task/taskmanager.h" +#include "widget/viewer/footageviewer.h" +#include "widget/viewer/viewer.h" +#include "widget/viewer/viewerdisplay.h" + +using namespace olive; + +namespace +{ + +QString DemoVideoPathT() +{ + return QDir(QStringLiteral(OAK_TEST_SOURCE_DIR)) + .filePath(QStringLiteral("tests/demo.mp4")); +} + +QString WorkerBinaryPathT() +{ + QDir dir(QCoreApplication::applicationDirPath()); + dir.cdUp(); + dir.cdUp(); + dir.cd(QStringLiteral("app")); +#if defined(_WIN32) + return dir.filePath(QStringLiteral("oak-render-worker.exe")); +#else + return dir.filePath(QStringLiteral("oak-render-worker")); +#endif +} + +double BrightnessOfWidget(QWidget *w) +{ + QPixmap pm = w->grab(); + QImage img = pm.toImage().convertToFormat(QImage::Format_RGB32); + if (img.isNull()) { + return -1.0; + } + double sum = 0; + int n = 0; + for (int y = img.height() / 4; y < img.height() * 3 / 4; y += 8) { + for (int x = img.width() / 4; x < img.width() * 3 / 4; x += 8) { + QRgb px = img.pixel(x, y); + sum += qRed(px) + qGreen(px) + qBlue(px); + n += 3; + } + } + return n ? sum / n / 255.0 : -1.0; +} + +class TestViewerWidget : public ViewerWidget { +public: + using ViewerWidget::display_widget; + using ViewerWidget::ViewerWidget; +}; + +class TestFootageViewerWidget : public FootageViewerWidget { +public: + using FootageViewerWidget::display_widget; + using FootageViewerWidget::FootageViewerWidget; +}; + +} // namespace + +class ViewerDisplayReproTest : public ::testing::TestWithParam { +protected: + static void SetUpTestSuite() + { + NodeFactory::Initialize(); + ColorManager::SetUpDefaultConfig(); + TaskManager::CreateInstance(); + ConformManager::CreateInstance(); + ProxyManager::CreateInstance(); + FrameManager::CreateInstance(); + ProjectSerializer::Initialize(); + DiskManager::CreateInstance(); + + const QString worker = WorkerBinaryPathT(); + if (QFileInfo::exists(worker)) { + qputenv("OAK_RENDER_WORKER", QFile::encodeName(worker)); + } + + if (!Core::instance()) { + new Core(Core::CoreParams()); + } + AudioManager::CreateInstance(); + } + + static void TearDownTestSuite() + { + AudioManager::DestroyInstance(); + DiskManager::DestroyInstance(); + ProjectSerializer::Destroy(); + FrameManager::DestroyInstance(); + ProxyManager::DestroyInstance(); + ConformManager::DestroyInstance(); + TaskManager::DestroyInstance(); + NodeFactory::Destroy(); + } + + void SetUp() override + { + backend_ = GetParam(); + if (backend_ != QStringLiteral("vulkan")) { + GTEST_SKIP() << "offscreen QOpenGLWidget cannot paint; Vulkan only"; + } + Config::Current()[QStringLiteral("GraphicsBackend")] = backend_; + + demo_path_ = DemoVideoPathT(); + ASSERT_TRUE(QFileInfo::exists(demo_path_)); + + project_ = std::make_unique(); + project_->Initialize(); + + footage_ = new Footage(demo_path_); + footage_->setParent(project_.get()); + ASSERT_TRUE(footage_->IsValid()); + + RenderManager::CreateInstance(); + RenderManager::instance()->GetCacher()->SetProject(project_.get()); + } + + void TearDown() override + { + // May be null when SetUp() skipped before creating the instance. + if (RenderManager::instance()) { + RenderManager::instance()->GetCacher()->SetProject(nullptr); + RenderManager::DestroyInstance(); + } + project_.reset(); + } + + // Pumps the event loop until the display widget has a texture or timeout. + bool WaitForTexture(ViewerDisplayWidget *display, int timeout_ms = 30000) + { + QElapsedTimer timer; + timer.start(); + while (!timer.hasExpired(timeout_ms)) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (display->GetCurrentTexture()) { + // Let a few more paints happen + for (int i = 0; i < 5; i++) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + QThread::msleep(20); + } + return true; + } + QThread::msleep(20); + } + return false; + } + + QString backend_; + QString demo_path_; + std::unique_ptr project_; + Footage *footage_ = nullptr; +}; + +// Footage previewed in the footage viewer (the "direct" case: the footage +// node itself is the viewer output, nothing in between). +TEST_P(ViewerDisplayReproTest, FootageViewerNotBlack) +{ + TestFootageViewerWidget *viewer = new TestFootageViewerWidget(); + viewer->resize(800, 600); + viewer->show(); + + viewer->ConnectViewerNode(footage_); + + ASSERT_TRUE(WaitForTexture(viewer->display_widget())) + << "Display widget never received a texture (backend=" + << backend_.toStdString() << ")"; + + double brightness = BrightnessOfWidget(viewer->display_widget()); + EXPECT_GT(brightness, 0.01) + << "Footage viewer paints BLACK (brightness=" << brightness + << ", backend=" << backend_.toStdString() << ")"; + + delete viewer; +} + +// Sequence with footage -> opacity -> sequence output (the "indirect" case). +TEST_P(ViewerDisplayReproTest, SequenceViewerIndirectNotBlack) +{ + Sequence *sequence = new Sequence(); + sequence->setParent(project_.get()); + + OpacityEffect *opacity = new OpacityEffect(); + opacity->setParent(project_.get()); + + Node::ConnectEdge(footage_, NodeInput(opacity, OpacityEffect::kTextureInput)); + Node::ConnectEdge(opacity, NodeInput(sequence, ViewerOutput::kTextureInput)); + + TestViewerWidget *viewer = new TestViewerWidget(); + viewer->resize(800, 600); + viewer->show(); + + viewer->ConnectViewerNode(sequence); + + ASSERT_TRUE(WaitForTexture(viewer->display_widget())) + << "Display widget never received a texture (backend=" + << backend_.toStdString() << ")"; + + double brightness = BrightnessOfWidget(viewer->display_widget()); + EXPECT_GT(brightness, 0.01) + << "Sequence viewer (indirect) paints BLACK (brightness=" << brightness + << ", backend=" << backend_.toStdString() << ")"; + + delete viewer; +} + +// Sequence with footage wired directly to the output (node-editor "direct" +// case). +TEST_P(ViewerDisplayReproTest, SequenceViewerDirectNotBlack) +{ + Sequence *sequence = new Sequence(); + sequence->setParent(project_.get()); + + Node::ConnectEdge(footage_, + NodeInput(sequence, ViewerOutput::kTextureInput)); + + TestViewerWidget *viewer = new TestViewerWidget(); + viewer->resize(800, 600); + viewer->show(); + + viewer->ConnectViewerNode(sequence); + + ASSERT_TRUE(WaitForTexture(viewer->display_widget())) + << "Display widget never received a texture (backend=" + << backend_.toStdString() << ")"; + + double brightness = BrightnessOfWidget(viewer->display_widget()); + EXPECT_GT(brightness, 0.01) + << "Sequence viewer (direct) paints BLACK (brightness=" << brightness + << ", backend=" << backend_.toStdString() << ")"; + + delete viewer; +} + +INSTANTIATE_TEST_SUITE_P(Backends, ViewerDisplayReproTest, + ::testing::Values(QStringLiteral("opengl"), + QStringLiteral("vulkan"))); + +// Simulates the user's actual workflow: a sequence is playing through its +// normal chain, then at RUNTIME the input is rewired to connect the footage +// directly to the output. Vulkan only (offscreen QOpenGLWidget cannot paint). +class ViewerRuntimeRewireTest : public ViewerDisplayReproTest { +protected: + double PumpAndMeasure(TestViewerWidget *viewer, int timeout_ms = 30000) + { + if (!WaitForTexture(viewer->display_widget(), timeout_ms)) { + return -1.0; + } + return BrightnessOfWidget(viewer->display_widget()); + } +}; + +TEST_P(ViewerRuntimeRewireTest, RewireToDirectConnectionNotBlack) +{ + Sequence *sequence = new Sequence(); + sequence->setParent(project_.get()); + + // Normal chain with a node in between: footage -> opacity -> sequence + OpacityEffect *opacity = new OpacityEffect(); + opacity->setParent(project_.get()); + Node::ConnectEdge(footage_, + NodeInput(opacity, OpacityEffect::kTextureInput)); + Node::ConnectEdge(opacity, NodeInput(sequence, ViewerOutput::kTextureInput)); + + TestViewerWidget *viewer = new TestViewerWidget(); + viewer->resize(800, 600); + viewer->show(); + viewer->ConnectViewerNode(sequence); + + double brightness = PumpAndMeasure(viewer); + ASSERT_GT(brightness, 0.01) + << "Precondition failed: indirect chain is already black"; + + // Now rewire at runtime: footage directly to the sequence output. + Node::DisconnectEdge(opacity, + NodeInput(sequence, ViewerOutput::kTextureInput)); + Node::ConnectEdge(footage_, + NodeInput(sequence, ViewerOutput::kTextureInput)); + + brightness = PumpAndMeasure(viewer); + EXPECT_GT(brightness, 0.01) + << "Viewer paints BLACK after rewiring to a direct connection " + << "(brightness=" << brightness << ")"; + + delete viewer; +} + +TEST_P(ViewerRuntimeRewireTest, RewireToIndirectConnectionNotBlack) +{ + Sequence *sequence = new Sequence(); + sequence->setParent(project_.get()); + + // Start direct: footage -> sequence + Node::ConnectEdge(footage_, + NodeInput(sequence, ViewerOutput::kTextureInput)); + + TestViewerWidget *viewer = new TestViewerWidget(); + viewer->resize(800, 600); + viewer->show(); + viewer->ConnectViewerNode(sequence); + + double brightness = PumpAndMeasure(viewer); + ASSERT_GT(brightness, 0.01) + << "Precondition failed: direct chain is already black"; + + // Insert a node at runtime: footage -> opacity -> sequence + OpacityEffect *opacity = new OpacityEffect(); + opacity->setParent(project_.get()); + Node::DisconnectEdge(footage_, + NodeInput(sequence, ViewerOutput::kTextureInput)); + Node::ConnectEdge(footage_, + NodeInput(opacity, OpacityEffect::kTextureInput)); + Node::ConnectEdge(opacity, NodeInput(sequence, ViewerOutput::kTextureInput)); + + brightness = PumpAndMeasure(viewer); + EXPECT_GT(brightness, 0.01) + << "Viewer paints BLACK after rewiring to an indirect connection " + << "(brightness=" << brightness << ")"; + + delete viewer; +} + +INSTANTIATE_TEST_SUITE_P(Backends, ViewerRuntimeRewireTest, + ::testing::Values(QStringLiteral("opengl"), + QStringLiteral("vulkan")));