Use 10-bit packed preview format and add OCIO LUT regression tests

Renderer / preview:
- Switch the preview/display readback format from F16 to packed
  10-bit RGBA (PixelFormat::U10) to halve GPU->CPU/IPC bandwidth
  while keeping 10-bit panel precision.
- Add U10 support to VideoParams, FFmpeg/OIIO/OCIO utility mappings,
  OpenGL (GL_RGB10_A2), Vulkan (VK_FORMAT_A2B10G10R10_UNORM_PACK32),
  and plugin bit-depth lookups.
- Update preview autocacher comment to reflect the new behavior.

OCIO LUT tests:
- Add four E2E-style ColorLutNode gtests that drive a SolidGenerator
  -> OCIOLutNode graph through NodeTraverser and compare resulting
  pixels on the CPU. They cover forward/inverse transforms and verify
  that switching LUT direction and LUT file updates both the processor
  and the output pixels.

Cleanup:
- Remove a leftover SolidGenerator::Value debug fprintf.
- Capture render worker stderr in RenderWorkerFootageTest for better
  diagnostics.
This commit is contained in:
2026-07-13 10:19:30 +08:00
parent a7f7890db0
commit 51ea3ca24e
13 changed files with 424 additions and 9 deletions
+6
View File
@@ -167,6 +167,8 @@ AVPixelFormat FFmpegUtils::GetFFmpegPixelFormat(const PixelFormat &pix_fmt,
switch (pix_fmt) {
case PixelFormat::U8:
return AV_PIX_FMT_RGB24;
case PixelFormat::U10:
return AV_PIX_FMT_NONE;
case PixelFormat::U16:
return AV_PIX_FMT_RGB48;
case PixelFormat::F16:
@@ -185,6 +187,8 @@ AVPixelFormat FFmpegUtils::GetFFmpegPixelFormat(const PixelFormat &pix_fmt,
switch (pix_fmt) {
case PixelFormat::U8:
return AV_PIX_FMT_RGBA;
case PixelFormat::U10:
return AV_PIX_FMT_NONE;
case PixelFormat::U16:
return AV_PIX_FMT_RGBA64;
case PixelFormat::F16:
@@ -209,6 +213,8 @@ PixelFormat FFmpegUtils::GetCompatiblePixelFormat(const PixelFormat &pix_fmt)
switch (pix_fmt) {
case PixelFormat::U8:
return PixelFormat::U8;
case PixelFormat::U10:
return PixelFormat::U8;
case PixelFormat::U16:
case PixelFormat::F16:
case PixelFormat::F32:
+2
View File
@@ -29,6 +29,8 @@ OCIO::BitDepth OCIOUtils::GetOCIOBitDepthFromPixelFormat(PixelFormat format)
switch (format) {
case PixelFormat::U8:
return OCIO::BIT_DEPTH_UINT8;
case PixelFormat::U10:
return OCIO::BIT_DEPTH_UINT10;
case PixelFormat::U16:
return OCIO::BIT_DEPTH_UINT16;
break;
+2
View File
@@ -39,6 +39,8 @@ public:
switch (format) {
case PixelFormat::U8:
return OIIO::TypeDesc::UINT8;
case PixelFormat::U10:
return OIIO::TypeDesc::UNKNOWN;
case PixelFormat::U16:
return OIIO::TypeDesc::UINT16;
case PixelFormat::F16:
-4
View File
@@ -66,10 +66,6 @@ void SolidGenerator::Value(const NodeValueRow &value,
const NodeGlobals &globals,
NodeValueTable *table) const
{
Color c = value[kColorInput].toColor();
fprintf(stderr,
"SolidGenerator::Value color=%f %f %f %f\n",
c.red(), c.green(), c.blue(), c.alpha());
table->Push(NodeValue::kTexture,
Texture::Job(globals.vparams(), ShaderJob(value)), this);
}
+2
View File
@@ -305,6 +305,8 @@ const std::string &olive::plugin::OliveClipInstance::getUnmappedBitDepth() const
return kBitDepthNoneStr;
case PixelFormat::U8:
return kBitDepthByteStr;
case PixelFormat::U10:
return kBitDepthNoneStr;
case PixelFormat::U16:
return kBitDepthShortStr;
case PixelFormat::F16:
+2
View File
@@ -433,6 +433,8 @@ bool FrameHashCache::SaveCacheFrame(const QString &filename,
fmt = QImage::Format_RGB888;
}
break;
case PixelFormat::U10:
break;
case PixelFormat::U16:
if (frame->channel_count() == VideoParams::kRGBAChannelCount) {
fmt = QImage::Format_RGBA64_Premultiplied;
+7
View File
@@ -835,6 +835,11 @@ GLint OpenGLRenderer::GetInternalFormat(PixelFormat format, int channel_layout)
return GL_RGBA8;
}
break;
case PixelFormat::U10:
if (channel_layout == 4) {
return GL_RGB10_A2;
}
break;
case PixelFormat::U16:
switch (channel_layout) {
case 1:
@@ -884,6 +889,8 @@ GLenum OpenGLRenderer::GetPixelType(PixelFormat format)
switch (format) {
case PixelFormat::U8:
return GL_UNSIGNED_BYTE;
case PixelFormat::U10:
return GL_UNSIGNED_INT_2_10_10_10_REV;
case PixelFormat::U16:
return GL_UNSIGNED_SHORT;
case PixelFormat::F16:
+5 -3
View File
@@ -688,9 +688,11 @@ RenderTicketWatcher *PreviewAutoCacher::RenderFrame(Node *node,
rvp.AddCache(frame_cache);
} else {
// Preview/display frames are rendered at reduced precision to cut the
// GPU->CPU readback and IPC transfer bandwidth in half. The internal
// render pipeline stays F32/ACEScg; only the final preview copy is F16.
rvp.force_format = PixelFormat::F16;
// GPU->CPU readback and IPC transfer bandwidth. The internal render
// pipeline stays F32/ACEScg; the final preview copy is packed 10-bit
// RGBA (4 bytes/pixel) to preserve 10-bit panel precision while halving
// bandwidth compared to F16.
rvp.force_format = PixelFormat::U10;
rvp.force_channel_count = VideoParams::kRGBAChannelCount;
}
+8
View File
@@ -209,6 +209,8 @@ int VideoParams::GetBytesPerChannel(PixelFormat format)
break;
case PixelFormat::U8:
return 1;
case PixelFormat::U10:
return 0; // packed format, use GetBytesPerPixel instead
case PixelFormat::U16:
case PixelFormat::F16:
return 2;
@@ -221,6 +223,10 @@ int VideoParams::GetBytesPerChannel(PixelFormat format)
int VideoParams::GetBytesPerPixel(PixelFormat format, int channels)
{
if (format == PixelFormat::U10) {
// Packed 10-bit RGBA10A2: 4 bytes per RGBA pixel regardless of channel count
return channels == VideoParams::kRGBAChannelCount ? 4 : 0;
}
return GetBytesPerChannel(format) * channels;
}
@@ -238,6 +244,8 @@ QString VideoParams::GetFormatName(PixelFormat format)
switch (format) {
case PixelFormat::U8:
return QCoreApplication::translate("VideoParams", "8-bit");
case PixelFormat::U10:
return QCoreApplication::translate("VideoParams", "10-bit Packed");
case PixelFormat::U16:
return QCoreApplication::translate("VideoParams", "16-bit Integer");
case PixelFormat::F16:
+7
View File
@@ -1127,6 +1127,11 @@ VkFormat VulkanRenderer::PixelFormatToVkFormat(PixelFormat format,
case 4: return VK_FORMAT_R8G8B8A8_UNORM;
}
break;
case PixelFormat::U10:
if (channel_count == 4) {
return VK_FORMAT_A2B10G10R10_UNORM_PACK32;
}
break;
case PixelFormat::U16:
switch (channel_count) {
case 1: return VK_FORMAT_R16_UNORM;
@@ -1203,6 +1208,8 @@ int VulkanRenderer::GetVkFormatBytesPerPixel(VkFormat format) const
return 3;
case VK_FORMAT_R8G8B8A8_UNORM:
return 4;
case VK_FORMAT_A2B10G10R10_UNORM_PACK32:
return 4;
case VK_FORMAT_R16_UNORM:
case VK_FORMAT_R16_SFLOAT:
return 2;
+374
View File
@@ -11,6 +11,9 @@
#include "node/color/ociolut/ociolut.h"
#include "node/color/threewaycolor/threewaycolor.h"
#include "node/factory.h"
#include "node/generator/solid/solid.h"
#include "node/project.h"
#include "node/traverser.h"
#include "render/colorprocessor.h"
namespace OCIO = OCIO_NAMESPACE;
@@ -46,6 +49,117 @@ QString WriteTestCube(QTemporaryDir *dir)
return path;
}
// CPU-only traverser that resolves SolidGenerator and ColorTransformJob jobs
// into real frames so we can compare pixels without a GPU/worker process.
class PixelColorTransformTraverser : public olive::NodeTraverser {
public:
void Resolve(olive::NodeValue &value)
{
ResolveJobs(value);
}
olive::FramePtr source_frame;
olive::FramePtr output_frame;
protected:
virtual void ProcessShader(olive::TexturePtr destination,
const olive::Node *node,
const olive::ShaderJob *job) override
{
Q_UNUSED(destination)
Q_UNUSED(node)
const olive::Color c =
job->GetValues().value(olive::SolidGenerator::kColorInput).toColor();
olive::VideoParams p = destination->params();
p.set_format(olive::core::PixelFormat::F32);
p.set_channel_count(olive::VideoParams::kRGBAChannelCount);
source_frame = olive::Frame::Create();
source_frame->set_video_params(p);
source_frame->allocate();
for (int y = 0; y < p.effective_height(); ++y) {
for (int x = 0; x < p.effective_width(); ++x) {
source_frame->set_pixel(x, y, c);
}
}
}
virtual void ProcessColorTransform(olive::TexturePtr destination,
const olive::Node *node,
const olive::ColorTransformJob *job) override
{
Q_UNUSED(destination)
Q_UNUSED(node)
olive::ColorProcessorPtr processor = job->GetColorProcessor();
if (!processor || !source_frame) {
return;
}
output_frame = olive::Frame::Create();
output_frame->set_video_params(source_frame->video_params());
output_frame->allocate();
std::memcpy(output_frame->data(), source_frame->const_data(),
source_frame->allocated_size());
processor->ConvertFrame(output_frame);
}
};
QString WriteTestCubeLut(QTemporaryDir *dir, const char *title,
float low, float high)
{
const QString path = QDir(dir->path()).filePath(
QStringLiteral("%1.cube").arg(QString::fromUtf8(title)));
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return QString();
}
QTextStream stream(&file);
stream << "TITLE \"" << title << "\"\n";
stream << "LUT_1D_SIZE 2\n";
stream << "DOMAIN_MIN 0.0 0.0 0.0\n";
stream << "DOMAIN_MAX 1.0 1.0 1.0\n";
stream << high << " " << high << " " << high << "\n";
stream << low << " " << low << " " << low << "\n";
file.close();
return path;
}
// Non-symmetric 1D LUT so forward and inverse produce different, predictable
// results. Table:
// 0.0 -> 0.00
// 0.5 -> 0.75
// 1.0 -> 1.00
// Forward: (0.25, 0.50, 0.75) -> (0.375, 0.750, 0.875)
// Inverse: (0.25, 0.50, 0.75) -> (0.167, 0.333, 0.500)
QString WriteAsymmetricCube(QTemporaryDir *dir)
{
const QString path =
QDir(dir->path()).filePath(QStringLiteral("asymmetric.cube"));
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
return QString();
}
const QByteArray data =
"TITLE \"Oak test asymmetric\"\n"
"LUT_1D_SIZE 3\n"
"DOMAIN_MIN 0.0 0.0 0.0\n"
"DOMAIN_MAX 1.0 1.0 1.0\n"
"0.00 0.00 0.00\n"
"0.75 0.75 0.75\n"
"1.00 1.00 1.00\n";
file.write(data);
file.close();
return path;
}
} // namespace
TEST(ColorLut, OcioSupportsCubeAnd3dlExtensions)
@@ -111,3 +225,263 @@ TEST(ColorV04, FactoryCreatesColorNodes)
EXPECT_FLOAT_EQ(neutral.blue(), 0.5f);
EXPECT_FLOAT_EQ(neutral.alpha(), 1.0f);
}
// -----------------------------------------------------------------------------
// E2E-style regression tests for OCIOLutNode.
//
// These tests build a tiny node graph (SolidGenerator -> OCIOLutNode), drive it
// through NodeTraverser, and compare the resulting pixels. They specifically
// exercise the previously broken paths:
// * OCIOLutNode::Value() must ensure the processor exists before rendering.
// * Switching the LUT direction must update the processor and change pixels.
// * Switching the LUT file must update the processor and change pixels.
// -----------------------------------------------------------------------------
TEST(ColorLutNode, ForwardDirectionInvertsPixels)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
project.Initialize();
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = WriteTestCubeLut(&dir, "invert", 0.0f, 1.0f);
ASSERT_FALSE(path.isEmpty());
auto *solid = new olive::SolidGenerator();
solid->setParent(&project);
solid->SetStandardValue(
olive::SolidGenerator::kColorInput,
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
auto *lut = new olive::OCIOLutNode();
lut->setParent(&project);
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
olive::Node::ConnectEdge(
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
const olive::VideoParams params(
16, 16, olive::core::PixelFormat::F32,
olive::VideoParams::kRGBAChannelCount);
PixelColorTransformTraverser traverser;
traverser.SetCacheVideoParams(params);
olive::NodeValueTable table = traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue tex_val = table.Get(olive::NodeValue::kTexture);
traverser.Resolve(tex_val);
ASSERT_TRUE(traverser.output_frame);
const olive::Color out = traverser.output_frame->get_pixel(0, 0);
EXPECT_NEAR(out.red(), 0.75f, 0.02f);
EXPECT_NEAR(out.green(), 0.50f, 0.02f);
EXPECT_NEAR(out.blue(), 0.25f, 0.02f);
EXPECT_NEAR(out.alpha(), 1.0f, 0.001f);
}
TEST(ColorLutNode, InverseDirectionReversesForwardTransform)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
project.Initialize();
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = WriteTestCubeLut(&dir, "invert", 0.0f, 1.0f);
ASSERT_FALSE(path.isEmpty());
auto *solid = new olive::SolidGenerator();
solid->setParent(&project);
solid->SetStandardValue(
olive::SolidGenerator::kColorInput,
QVariant::fromValue(olive::Color(0.75f, 0.50f, 0.25f, 1.0f)));
auto *lut = new olive::OCIOLutNode();
lut->setParent(&project);
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 1); // Inverse
olive::Node::ConnectEdge(
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
const olive::VideoParams params(
16, 16, olive::core::PixelFormat::F32,
olive::VideoParams::kRGBAChannelCount);
PixelColorTransformTraverser traverser;
traverser.SetCacheVideoParams(params);
olive::NodeValueTable table = traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue tex_val = table.Get(olive::NodeValue::kTexture);
traverser.Resolve(tex_val);
ASSERT_TRUE(traverser.output_frame);
const olive::Color out = traverser.output_frame->get_pixel(0, 0);
EXPECT_NEAR(out.red(), 0.25f, 0.02f);
EXPECT_NEAR(out.green(), 0.50f, 0.02f);
EXPECT_NEAR(out.blue(), 0.75f, 0.02f);
EXPECT_NEAR(out.alpha(), 1.0f, 0.001f);
}
TEST(ColorLutNode, SwitchingDirectionUpdatesProcessorAndPixels)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
project.Initialize();
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = WriteAsymmetricCube(&dir);
ASSERT_FALSE(path.isEmpty());
auto *solid = new olive::SolidGenerator();
solid->setParent(&project);
solid->SetStandardValue(
olive::SolidGenerator::kColorInput,
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
auto *lut = new olive::OCIOLutNode();
lut->setParent(&project);
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, path);
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
olive::Node::ConnectEdge(
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
const olive::VideoParams params(
16, 16, olive::core::PixelFormat::F32,
olive::VideoParams::kRGBAChannelCount);
// First render: forward direction.
PixelColorTransformTraverser forward_traverser;
forward_traverser.SetCacheVideoParams(params);
olive::NodeValueTable forward_table = forward_traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue forward_tex =
forward_table.Get(olive::NodeValue::kTexture);
forward_traverser.Resolve(forward_tex);
ASSERT_TRUE(forward_traverser.output_frame);
const olive::Color forward_out =
forward_traverser.output_frame->get_pixel(0, 0);
EXPECT_NEAR(forward_out.red(), 0.375f, 0.02f);
EXPECT_NEAR(forward_out.green(), 0.750f, 0.02f);
EXPECT_NEAR(forward_out.blue(), 0.875f, 0.02f);
// Switch direction. Before the Value()/EnsureProcessor() fix, the node
// would keep using the old forward processor.
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 1); // Inverse
PixelColorTransformTraverser inverse_traverser;
inverse_traverser.SetCacheVideoParams(params);
olive::NodeValueTable inverse_table = inverse_traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue inverse_tex =
inverse_table.Get(olive::NodeValue::kTexture);
inverse_traverser.Resolve(inverse_tex);
ASSERT_TRUE(inverse_traverser.output_frame);
const olive::Color inverse_out =
inverse_traverser.output_frame->get_pixel(0, 0);
// Inverse of the asymmetric LUT moves the same color to a darker result.
EXPECT_NEAR(inverse_out.red(), 0.167f, 0.02f);
EXPECT_NEAR(inverse_out.green(), 0.333f, 0.02f);
EXPECT_NEAR(inverse_out.blue(), 0.500f, 0.02f);
// The two outputs must differ from each other.
EXPECT_GT(std::abs(forward_out.red() - inverse_out.red()), 0.1f);
EXPECT_GT(std::abs(forward_out.green() - inverse_out.green()), 0.1f);
EXPECT_GT(std::abs(forward_out.blue() - inverse_out.blue()), 0.1f);
}
TEST(ColorLutNode, SwitchingFileUpdatesProcessorAndPixels)
{
olive::ColorManager::SetUpDefaultConfig();
olive::Project project;
project.Initialize();
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString invert_path = WriteTestCubeLut(
&dir, "invert", 0.0f, 1.0f);
const QString boost_path = WriteTestCubeLut(
&dir, "boost", 0.5f, 1.0f);
ASSERT_FALSE(invert_path.isEmpty());
ASSERT_FALSE(boost_path.isEmpty());
auto *solid = new olive::SolidGenerator();
solid->setParent(&project);
solid->SetStandardValue(
olive::SolidGenerator::kColorInput,
QVariant::fromValue(olive::Color(0.25f, 0.50f, 0.75f, 1.0f)));
auto *lut = new olive::OCIOLutNode();
lut->setParent(&project);
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, invert_path);
lut->SetStandardValue(olive::OCIOLutNode::kDirectionInput, 0); // Forward
olive::Node::ConnectEdge(
solid, olive::NodeInput(lut, olive::OCIOLutNode::kTextureInput));
const olive::VideoParams params(
16, 16, olive::core::PixelFormat::F32,
olive::VideoParams::kRGBAChannelCount);
// Render with invert LUT.
PixelColorTransformTraverser invert_traverser;
invert_traverser.SetCacheVideoParams(params);
olive::NodeValueTable invert_table = invert_traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue invert_tex = invert_table.Get(olive::NodeValue::kTexture);
invert_traverser.Resolve(invert_tex);
ASSERT_TRUE(invert_traverser.output_frame);
const olive::Color invert_out =
invert_traverser.output_frame->get_pixel(0, 0);
EXPECT_NEAR(invert_out.red(), 0.75f, 0.02f);
// Switch to a different LUT file. Before the fix, the node could keep the
// old invert processor cached and the output would not change.
lut->SetStandardValue(olive::OCIOLutNode::kFileInput, boost_path);
PixelColorTransformTraverser boost_traverser;
boost_traverser.SetCacheVideoParams(params);
olive::NodeValueTable boost_table = boost_traverser.GenerateTable(
lut, olive::TimeRange(olive::core::rational(0),
olive::core::rational(1, 30)));
olive::NodeValue boost_tex = boost_table.Get(olive::NodeValue::kTexture);
boost_traverser.Resolve(boost_tex);
ASSERT_TRUE(boost_traverser.output_frame);
const olive::Color boost_out =
boost_traverser.output_frame->get_pixel(0, 0);
// boost LUT maps:
// 0.0 -> 1.0, 1.0 -> 0.5 (linear: f(x) = 1 - 0.5*x)
// so (0.25, 0.50, 0.75) -> (0.875, 0.750, 0.625).
EXPECT_NEAR(boost_out.red(), 0.875f, 0.02f);
EXPECT_NEAR(boost_out.green(), 0.750f, 0.02f);
EXPECT_NEAR(boost_out.blue(), 0.625f, 0.02f);
// The two outputs must differ (boost raises all channels vs invert).
EXPECT_GT(std::abs(boost_out.red() - invert_out.red()), 0.1f);
EXPECT_GT(std::abs(boost_out.green() - invert_out.green()), 0.1f);
EXPECT_GT(std::abs(boost_out.blue() - invert_out.blue()), 0.1f);
}
+8 -1
View File
@@ -232,7 +232,7 @@ protected:
}
// ---- spawn worker ----
worker_.setProcessChannelMode(QProcess::ForwardedErrorChannel);
worker_.setProcessChannelMode(QProcess::SeparateChannels);
worker_.start(worker_path_, QStringList{QStringLiteral("--backend"), backend});
if (!worker_.waitForStarted(kTimeoutMs)) {
return false;
@@ -362,6 +362,13 @@ protected:
if (worker_.state() == QProcess::NotRunning) {
std::cerr << "WaitForMessage: worker exited with code "
<< worker_.exitCode() << std::endl;
std::cerr << "Worker stdout buffer: "
<< read_buffer_.toStdString() << std::endl;
QByteArray err = worker_.readAllStandardError();
if (!err.isEmpty()) {
std::cerr << "Worker stderr:\n"
<< err.toStdString() << std::endl;
}
return false;
}
}