color: input colorspace auto-detection, HDR export tags, LGG/white balance nodes, more LUT formats, waveform parade

- media color primaries/transfer tags now flow from the FFmpeg probe
  through VideoParams into Footage::get_colorspace_to_use(); precedence
  is user override > media tags > project default
- export nclc tags derive from the output colorspace (PQ/HLG/BT.2020,
  P3, sRGB, Rec.601, Rec.709) instead of hardcoded BT.709
- new OCIO Color Grading (Log) node (lift/gamma/gain) and White Balance
  node (kelvin temperature + tint, HDR-safe)
- LUT whitelist extended to 9 OCIO-supported formats
- waveform scope gains an RGB parade mode (GPU and software paths)
- tests updated for the new colorspace precedence
This commit is contained in:
2026-07-19 21:45:51 +08:00
parent a7ddc0f114
commit 76f5c2a65b
37 changed files with 1449 additions and 40 deletions
+19
View File
@@ -41,3 +41,22 @@ TEST(CodecDecoder, RetrieveVideoFrameFromDemoMp4)
}
EXPECT_TRUE(has_nonzero_byte);
}
TEST(CodecDecoder, ProbeReportsColorTags)
{
const QString path = QDir(QStringLiteral(OAK_TEST_SOURCE_DIR))
.filePath(QStringLiteral("tests/demo.mp4"));
ASSERT_TRUE(QFileInfo::exists(path));
olive::DecoderPtr decoder =
olive::Decoder::create_from_id(QStringLiteral("ffmpeg"));
ASSERT_TRUE(decoder);
const olive::FootageDescription desc = decoder->probe(path, nullptr);
const QVector<olive::VideoParams> &streams = desc.get_video_streams();
ASSERT_FALSE(streams.isEmpty());
// tests/demo.mp4 is tagged BT.709 primaries and transfer
EXPECT_EQ(streams.first().color_primaries(), 1);
EXPECT_EQ(streams.first().color_transfer(), 1);
}
+83
View File
@@ -118,3 +118,86 @@ TEST(CodecFFmpegEncoder, EncodePngVideoAndProbeBack)
fb_probe_close(probe);
fb_probe_free(&probe);
}
TEST(CodecFFmpegEncoder, ColorTagsForColorspace)
{
int primaries = 0, trc = 0, matrix = 0;
ASSERT_TRUE(olive::FFmpegEncoder::get_color_tags_for_colorspace(
QStringLiteral("Rec.2020 PQ"), &primaries, &trc, &matrix));
EXPECT_EQ(primaries, fb_color_primaries_bt2020);
EXPECT_EQ(trc, fb_color_trc_pq);
EXPECT_EQ(matrix, fb_col_spc_b_t2020_ncl);
ASSERT_TRUE(olive::FFmpegEncoder::get_color_tags_for_colorspace(
QStringLiteral("Rec.2020 HLG"), &primaries, &trc, &matrix));
EXPECT_EQ(primaries, fb_color_primaries_bt2020);
EXPECT_EQ(trc, fb_color_trc_hlg);
EXPECT_EQ(matrix, fb_col_spc_b_t2020_ncl);
ASSERT_TRUE(olive::FFmpegEncoder::get_color_tags_for_colorspace(
QStringLiteral("sRGB OETF"), &primaries, &trc, &matrix));
EXPECT_EQ(primaries, fb_color_primaries_bt709);
EXPECT_EQ(trc, fb_color_trc_srgb);
EXPECT_EQ(matrix, fb_col_spc_b_t709);
ASSERT_TRUE(olive::FFmpegEncoder::get_color_tags_for_colorspace(
QStringLiteral("Rec.709 OETF"), &primaries, &trc, &matrix));
EXPECT_EQ(primaries, fb_color_primaries_bt709);
EXPECT_EQ(trc, fb_color_trc_bt709);
EXPECT_EQ(matrix, fb_col_spc_b_t709);
// Unknown/linear spaces must not guess tags
EXPECT_FALSE(olive::FFmpegEncoder::get_color_tags_for_colorspace(
QStringLiteral("Linear"), &primaries, &trc, &matrix));
}
TEST(CodecFFmpegEncoder, WritesHdrColorTags)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path = dir.filePath(QStringLiteral("hdr_test.mkv"));
olive::EncodingParams params;
params.set_filename(path);
olive::VideoParams video_params(64, 64, olive::core::PixelFormat::u8,
olive::VideoParams::k_rgba_channel_count);
video_params.set_frame_rate(olive::core::Rational(30, 1));
params.enable_video(video_params, olive::ExportCodec::k_codec_h264);
params.set_video_pix_fmt(QStringLiteral("yuv420p"));
params.set_color_transform(
olive::ColorTransform(QStringLiteral("Rec.2020 PQ")));
olive::FFmpegEncoder encoder(params);
ASSERT_TRUE(encoder.open()) << encoder.get_error().toStdString();
for (int f = 0; f < 3; f++) {
olive::FramePtr frame = olive::Frame::create();
frame->set_video_params(video_params);
ASSERT_TRUE(frame->allocate());
memset(frame->data(), 128, frame->allocated_size());
ASSERT_TRUE(encoder.write_frame(frame, olive::core::Rational(f, 30)))
<< encoder.get_error().toStdString();
}
encoder.close();
FBProbe *probe = fb_probe_create();
ASSERT_NE(probe, nullptr);
ASSERT_EQ(fb_probe_open(probe, path.toUtf8().constData()), 0);
bool found_video = false;
const int stream_count = fb_probe_get_stream_count(probe);
for (int i = 0; i < stream_count; i++) {
FBStreamInfo info;
if (fb_probe_get_stream_info(probe, i, &info) == 0 &&
info.codec_type == fb_media_type_video) {
found_video = true;
EXPECT_EQ(info.color_primaries, fb_color_primaries_bt2020);
EXPECT_EQ(info.color_trc, fb_color_trc_pq);
}
}
EXPECT_TRUE(found_video);
fb_probe_close(probe);
fb_probe_free(&probe);
}
+50
View File
@@ -826,6 +826,56 @@ TEST(LUTLibrary, SupportsCubeAnd3dlExtensions)
EXPECT_FALSE(olive::LUTLibrary::is_supported_extension(QString()));
}
TEST(LUTLibrary, SupportsExtendedOcioFormats)
{
for (const QString &ext :
{ QStringLiteral("spi1d"), QStringLiteral("spi3d"),
QStringLiteral("spimtx"), QStringLiteral("csp"),
QStringLiteral("clf"), QStringLiteral("ctf"), QStringLiteral("cub") }) {
EXPECT_TRUE(olive::LUTLibrary::is_supported_extension(ext))
<< ext.toStdString();
EXPECT_TRUE(olive::LUTLibrary::is_supported_extension(
QStringLiteral(".") + ext.toUpper()))
<< ext.toStdString();
}
}
TEST(ColorLut, Spi1dFileTransformConvertsColor)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const QString path =
QDir(dir.path()).filePath(QStringLiteral("invert.spi1d"));
QFile file(path);
ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
file.write("Version 1\n"
"From 0.0 1.0\n"
"Length 2\n"
"Components 3\n"
"{\n"
"1.0 1.0 1.0\n"
"0.0 0.0 0.0\n"
"}\n");
file.close();
ocio::FileTransformRcPtr transform = ocio::FileTransform::Create();
transform->setSrc(path.toUtf8().constData());
transform->setInterpolation(ocio::INTERP_LINEAR);
transform->setDirection(ocio::TRANSFORM_DIR_FORWARD);
ocio::ConstConfigRcPtr config = ocio::Config::CreateRaw();
olive::ColorProcessorPtr processor =
olive::ColorProcessor::create(config->getProcessor(transform));
ASSERT_TRUE(processor);
const olive::Color out =
processor->convert_color(olive::Color(0.25f, 0.50f, 0.75f, 1.0f));
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(LUTLibrary, DirectoryRoundTripCleansAndDeduplicates)
{
const QString previous =
+4 -2
View File
@@ -331,9 +331,11 @@ TEST_F(FootageProbeTest, ProbedFootageValuePushesRealStreamJobs)
ASSERT_NE(texture, nullptr);
EXPECT_EQ(texture->params().width(), 1920);
EXPECT_EQ(texture->params().height(), 1080);
// The probed stream has no colorspace, so the project default is used
// The probed stream has no explicit colorspace. The media is tagged
// BT.709, which auto-detects to the config's Rec.709 input space and
// takes precedence over the project default
EXPECT_EQ(texture->params().colorspace(),
QStringLiteral("ProbeInputSpace"));
QStringLiteral("Rec.709 OETF"));
const auto *video_job =
static_cast<const olive::FootageJob *>(texture->job());
+197
View File
@@ -8,8 +8,13 @@
#include "node/color/displaytransform/displaytransform.h"
#include "node/color/ociobase/ociobase.h"
#include "node/color/ociogradingtransformlinear/ociogradingtransformlinear.h"
#include "node/color/ociogradingtransformlog/ociogradingtransformlog.h"
#include "node/color/threewaycolor/threewaycolor.h"
#include "node/color/whitebalance/whitebalance.h"
#include "node/factory.h"
#include "node/project.h"
#include "node/project/serializer/serializer.h"
#include "render/diskmanager.h"
#include "render/job/colortransformjob.h"
#include "render/job/shaderjob.h"
#include "render/texture.h"
@@ -79,6 +84,198 @@ TEST(OCIOBaseNode, PushesNothingWhenTextureInputEmpty)
EXPECT_EQ(table.count(), 0);
}
// -----------------------------------------------------------------------------
// ColorManager input colorspace auto-detection
// -----------------------------------------------------------------------------
TEST(ColorManager, DetectsColorspaceFromFfmpegTags)
{
olive::ColorManager::set_up_default_config();
olive::ProjectSerializer::initialize();
olive::DiskManager::create_instance();
olive::Project project;
project.initialize();
olive::ColorManager *cm = project.color_manager();
ASSERT_NE(cm, nullptr);
EXPECT_EQ(cm->get_colorspace_for_ffmpeg_tags(1, 1),
QStringLiteral("Rec.709 OETF"));
EXPECT_EQ(cm->get_colorspace_for_ffmpeg_tags(1, 13),
QStringLiteral("sRGB OETF"));
EXPECT_EQ(cm->get_colorspace_for_ffmpeg_tags(6, 6),
QStringLiteral("Rec.601 OETF (NTSC)"));
EXPECT_EQ(cm->get_colorspace_for_ffmpeg_tags(5, 5),
QStringLiteral("Rec.601 OETF (PAL)"));
// The embedded config has no PQ/HLG/Rec.2020 colorspace
EXPECT_TRUE(cm->get_colorspace_for_ffmpeg_tags(9, 16).isEmpty());
EXPECT_TRUE(cm->get_colorspace_for_ffmpeg_tags(9, 18).isEmpty());
// Unknown tags never guess
EXPECT_TRUE(cm->get_colorspace_for_ffmpeg_tags(0, 0).isEmpty());
EXPECT_TRUE(cm->get_colorspace_for_ffmpeg_tags(2, 2).isEmpty());
olive::DiskManager::destroy_instance();
olive::ProjectSerializer::destroy();
}
// -----------------------------------------------------------------------------
// OCIOGradingTransformLogNode (lift/gamma/gain)
// -----------------------------------------------------------------------------
TEST(OCIOGradingTransformLogNode, InputIdsMatchOcioUniformNames)
{
olive::OCIOGradingTransformLogNode node;
// The input ids double as the OCIO GPU uniform names of the dynamic
// GRADING_LOG transform (verified against OCIO's generated shader)
EXPECT_EQ(olive::OCIOGradingTransformLogNode::k_lift_input,
QStringLiteral("ocio_grading_primary_brightness"));
EXPECT_EQ(olive::OCIOGradingTransformLogNode::k_gain_input,
QStringLiteral("ocio_grading_primary_contrast"));
EXPECT_EQ(olive::OCIOGradingTransformLogNode::k_gamma_input,
QStringLiteral("ocio_grading_primary_gamma"));
EXPECT_TRUE(node.has_input_with_id(
olive::OCIOGradingTransformLogNode::k_saturation_input));
EXPECT_TRUE(
node.has_input_with_id(olive::OCIOGradingTransformLogNode::k_pivot_input));
// GRADING_LOG defaults (see ocio::GradingPrimary)
EXPECT_EQ(node.get_standard_value(
olive::OCIOGradingTransformLogNode::k_lift_input),
QVariant::fromValue(QVector4D(0, 0, 0, 0)));
EXPECT_EQ(node.get_standard_value(
olive::OCIOGradingTransformLogNode::k_gain_input),
QVariant::fromValue(QVector4D(1, 1, 1, 1)));
EXPECT_EQ(node.get_standard_value(
olive::OCIOGradingTransformLogNode::k_gamma_input),
QVariant::fromValue(QVector4D(1, 1, 1, 1)));
EXPECT_EQ(node.get_standard_value(
olive::OCIOGradingTransformLogNode::k_pivot_input),
QVariant::fromValue(-0.2));
}
TEST(OCIOGradingTransformLogNode, ValueMapsWheelsToOcioUniforms)
{
olive::ColorManager::set_up_default_config();
olive::ProjectSerializer::initialize();
olive::DiskManager::create_instance();
olive::Project project;
project.initialize();
auto *node = new olive::OCIOGradingTransformLogNode();
node->setParent(&project);
olive::NodeValueRow row;
row.insert(olive::OCIOBaseNode::k_texture_input,
olive::NodeValue(olive::NodeValue::k_texture,
make_dummy_texture()));
row.insert(olive::OCIOGradingTransformLogNode::k_lift_input,
olive::NodeValue(olive::NodeValue::k_vec4,
QVector4D(1.0, 0.25, 0.0, 0.0)));
row.insert(olive::OCIOGradingTransformLogNode::k_gain_input,
olive::NodeValue(olive::NodeValue::k_vec4,
QVector4D(2.0, 0.5, 1.0, 1.0)));
row.insert(olive::OCIOGradingTransformLogNode::k_gamma_input,
olive::NodeValue(olive::NodeValue::k_vec4,
QVector4D(2.0, 1.0, 0.5, 1.0)));
olive::NodeValueTable table;
node->value(row, olive::NodeGlobals(), &table);
ASSERT_EQ(table.count(), 1);
olive::TexturePtr tex = table.get(olive::NodeValue::k_texture).to_texture();
ASSERT_TRUE(tex);
auto *job = dynamic_cast<olive::ColorTransformJob *>(tex->job());
ASSERT_NE(job, nullptr);
// Lift is additive: RGB += master
const QVector3D lift =
job->get(olive::OCIOGradingTransformLogNode::k_lift_input).to_vec3();
EXPECT_FLOAT_EQ(lift.x(), 1.25f);
EXPECT_FLOAT_EQ(lift.y(), 1.0f);
EXPECT_FLOAT_EQ(lift.z(), 1.0f);
// Gain multiplies: RGB *= master
const QVector3D gain =
job->get(olive::OCIOGradingTransformLogNode::k_gain_input).to_vec3();
EXPECT_FLOAT_EQ(gain.x(), 1.0f);
EXPECT_FLOAT_EQ(gain.y(), 2.0f);
EXPECT_FLOAT_EQ(gain.z(), 2.0f);
// Gamma multiplies: RGB *= master
const QVector3D gamma =
job->get(olive::OCIOGradingTransformLogNode::k_gamma_input).to_vec3();
EXPECT_FLOAT_EQ(gamma.x(), 2.0f);
EXPECT_FLOAT_EQ(gamma.y(), 1.0f);
EXPECT_FLOAT_EQ(gamma.z(), 2.0f);
olive::DiskManager::destroy_instance();
olive::ProjectSerializer::destroy();
}
TEST(OCIOGradingTransformLogNode, FactoryCreatesNode)
{
std::unique_ptr<olive::Node> node(
olive::NodeFactory::create_from_factory_index(
olive::NodeFactory::k_ocio_grading_transform_log));
ASSERT_NE(node, nullptr);
EXPECT_EQ(node->id(),
QStringLiteral("org.olivevideoeditor.Olive.ociogradingtransformlog"));
}
// -----------------------------------------------------------------------------
// WhiteBalanceNode
// -----------------------------------------------------------------------------
TEST(WhiteBalanceNode, NeutralTemperatureKeepsNearUnityGain)
{
const QVector3D gain =
olive::WhiteBalanceNode::get_gain_for_temperature(6500.0, 0.0);
EXPECT_NEAR(gain.x(), 1.0f, 0.02f);
EXPECT_FLOAT_EQ(gain.y(), 1.0f);
EXPECT_NEAR(gain.z(), 1.0f, 0.02f);
}
TEST(WhiteBalanceNode, WarmAndCoolShiftAsExpected)
{
const QVector3D warm =
olive::WhiteBalanceNode::get_gain_for_temperature(3000.0, 0.0);
EXPECT_GT(warm.x(), warm.z()); // warm light: red over blue
EXPECT_FLOAT_EQ(warm.y(), 1.0f);
const QVector3D cool =
olive::WhiteBalanceNode::get_gain_for_temperature(10000.0, 0.0);
EXPECT_GT(cool.z(), cool.x()); // cool light: blue over red
}
TEST(WhiteBalanceNode, TintMovesGreenMagentaAxis)
{
const QVector3D magenta =
olive::WhiteBalanceNode::get_gain_for_temperature(6500.0, -0.5);
const QVector3D green =
olive::WhiteBalanceNode::get_gain_for_temperature(6500.0, 0.5);
EXPECT_LT(magenta.y(), 1.0f);
EXPECT_GT(green.y(), 1.0f);
}
TEST(WhiteBalanceNode, FactoryCreatesNode)
{
std::unique_ptr<olive::Node> node(
olive::NodeFactory::create_from_factory_index(
olive::NodeFactory::k_white_balance));
ASSERT_NE(node, nullptr);
EXPECT_EQ(node->id(),
QStringLiteral("org.olivevideoeditor.Olive.whitebalance"));
EXPECT_TRUE(
node->has_input_with_id(olive::WhiteBalanceNode::k_temperature_input));
EXPECT_TRUE(node->has_input_with_id(olive::WhiteBalanceNode::k_tint_input));
}
// -----------------------------------------------------------------------------
// DisplayTransformNode
// -----------------------------------------------------------------------------
+4
View File
@@ -14,6 +14,8 @@ TEST(RenderVideoParams, SaveLoadRoundTrip)
params.set_frame_rate(olive::core::Rational(24, 1));
params.set_pixel_aspect_ratio(olive::core::Rational(1, 1));
params.set_colorspace(QStringLiteral("test"));
params.set_color_primaries(9);
params.set_color_transfer(16);
QByteArray xml;
QBuffer buffer(&xml);
@@ -39,4 +41,6 @@ TEST(RenderVideoParams, SaveLoadRoundTrip)
EXPECT_EQ(loaded.frame_rate(), olive::core::Rational(24, 1));
EXPECT_EQ(loaded.pixel_aspect_ratio(), olive::core::Rational(1, 1));
EXPECT_EQ(loaded.colorspace(), QStringLiteral("test"));
EXPECT_EQ(loaded.color_primaries(), 9);
EXPECT_EQ(loaded.color_transfer(), 16);
}