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.
This commit is contained in:
@@ -141,6 +141,30 @@ rational ClipBlock::media_in() const
|
|||||||
return GetStandardValue(kMediaInInput).value<rational>();
|
return GetStandardValue(kMediaInInput).value<rational>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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::Type>{ NodeValue::kTexture });
|
||||||
|
case Track::kAudio:
|
||||||
|
return ValueHint(QVector<NodeValue::Type>{ NodeValue::kSamples });
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super::GetValueHintForInput(input, element);
|
||||||
|
}
|
||||||
|
|
||||||
void ClipBlock::set_media_in(const rational &media_in)
|
void ClipBlock::set_media_in(const rational &media_in)
|
||||||
{
|
{
|
||||||
SetStandardValue(kMediaInInput, QVariant::fromValue(media_in));
|
SetStandardValue(kMediaInInput, QVariant::fromValue(media_in));
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
virtual Node::ValueHint
|
||||||
|
GetValueHintForInput(const QString &input, int element = -1) const override;
|
||||||
|
|
||||||
rational media_in() const;
|
rational media_in() const;
|
||||||
void set_media_in(const rational &media_in);
|
void set_media_in(const rational &media_in);
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -802,7 +802,8 @@ public:
|
|||||||
return value_hints_;
|
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 });
|
return value_hints_.value({ input, element });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ add_executable(olive-gtest
|
|||||||
render_ipc_test.cpp
|
render_ipc_test.cpp
|
||||||
render_ticket_test.cpp
|
render_ticket_test.cpp
|
||||||
render_worker_footage_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
|
project_serializer_test.cpp
|
||||||
proxy_manager_test.cpp
|
proxy_manager_test.cpp
|
||||||
timeline_marker_test.cpp
|
timeline_marker_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 <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")));
|
||||||
@@ -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 <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/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<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 RenderDirectConnectionTest
|
||||||
|
: 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();
|
||||||
|
|
||||||
|
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<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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<PixelFormat::Format>(
|
||||||
|
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")));
|
||||||
@@ -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 <gtest/gtest.h>
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QElapsedTimer>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QImage>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
|
#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<QString> {
|
||||||
|
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>();
|
||||||
|
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> 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")));
|
||||||
Reference in New Issue
Block a user