From 76f5c2a65bae8f14ca6a89b516c14ba19a5c9329 Mon Sep 17 00:00:00 2001 From: Mike Solar Date: Sun, 19 Jul 2026 21:45:51 +0800 Subject: [PATCH] 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 --- app/codec/ffmpeg/ffmpegdecoder.cpp | 2 + app/codec/ffmpeg/ffmpegencoder.cpp | 80 +++++ app/codec/ffmpeg/ffmpegencoder.h | 12 + app/config/config.cpp | 2 + app/node/color/CMakeLists.txt | 2 + app/node/color/colormanager/colormanager.cpp | 47 +++ app/node/color/colormanager/colormanager.h | 10 + .../ociogradingtransformlog/CMakeLists.txt | 23 ++ .../ociogradingtransformlog.cpp | 302 ++++++++++++++++++ .../ociogradingtransformlog.h | 91 ++++++ app/node/color/ociolut/ociolut.cpp | 16 +- app/node/color/whitebalance/CMakeLists.txt | 22 ++ app/node/color/whitebalance/whitebalance.cpp | 156 +++++++++ app/node/color/whitebalance/whitebalance.h | 74 +++++ app/node/factory.cpp | 6 + app/node/factory.h | 2 + app/node/project/footage/footage.cpp | 15 +- app/render/lutlibrary.cpp | 14 +- app/render/lutlibrary.h | 9 +- app/render/videoparams.cpp | 8 + app/render/videoparams.h | 29 ++ app/shaders/rgbwaveform.frag | 57 +++- app/shaders/whitebalance.frag | 15 + app/widget/scope/waveform/waveform.cpp | 56 +++- app/widget/scope/waveform/waveform.h | 12 + docs/project-file-reference.md | 4 + .../include/ffmpeg_bridge/ffmpeg_bridge.h | 30 ++ ffmpeg_bridge/src/decoder.cpp | 2 + ffmpeg_bridge/src/encoder.cpp | 15 +- ffmpeg_bridge/src/internal.h | 15 + ffmpeg_bridge/src/probe.cpp | 2 + tests/gtest/codec_decoder_test.cpp | 19 ++ tests/gtest/codec_ffmpegencoder_test.cpp | 83 +++++ tests/gtest/color_lut_test.cpp | 50 +++ tests/gtest/footage_probe_test.cpp | 6 +- tests/gtest/node_color_test.cpp | 197 ++++++++++++ tests/gtest/render_videoparams_test.cpp | 4 + 37 files changed, 1449 insertions(+), 40 deletions(-) create mode 100644 app/node/color/ociogradingtransformlog/CMakeLists.txt create mode 100644 app/node/color/ociogradingtransformlog/ociogradingtransformlog.cpp create mode 100644 app/node/color/ociogradingtransformlog/ociogradingtransformlog.h create mode 100644 app/node/color/whitebalance/CMakeLists.txt create mode 100644 app/node/color/whitebalance/whitebalance.cpp create mode 100644 app/node/color/whitebalance/whitebalance.h create mode 100644 app/shaders/whitebalance.frag diff --git a/app/codec/ffmpeg/ffmpegdecoder.cpp b/app/codec/ffmpeg/ffmpegdecoder.cpp index ddd2100d4..5cf2866b0 100644 --- a/app/codec/ffmpeg/ffmpegdecoder.cpp +++ b/app/codec/ffmpeg/ffmpegdecoder.cpp @@ -677,6 +677,8 @@ FootageDescription FFmpegDecoder::probe(const QString &filename, stream.set_color_range(info.color_range == fb_color_range_jpeg ? VideoParams::k_color_range_full : VideoParams::k_color_range_limited); + stream.set_color_primaries(info.color_primaries); + stream.set_color_transfer(info.color_trc); stream.set_premultiplied_alpha(false); desc.add_video_stream(stream); diff --git a/app/codec/ffmpeg/ffmpegencoder.cpp b/app/codec/ffmpeg/ffmpegencoder.cpp index 54de69731..34d5e4146 100644 --- a/app/codec/ffmpeg/ffmpegencoder.cpp +++ b/app/codec/ffmpeg/ffmpegencoder.cpp @@ -38,6 +38,73 @@ FFmpegEncoder::FFmpegEncoder(const EncodingParams ¶ms) { } +bool FFmpegEncoder::get_color_tags_for_colorspace(const QString &colorspace, + int *primaries, int *trc, + int *matrix) +{ + const QString name = colorspace.toLower(); + + if (name.contains(QStringLiteral("pq")) || + name.contains(QStringLiteral("2084"))) { + *primaries = fb_color_primaries_bt2020; + *trc = fb_color_trc_pq; + *matrix = fb_col_spc_b_t2020_ncl; + return true; + } + + if (name.contains(QStringLiteral("hlg"))) { + *primaries = fb_color_primaries_bt2020; + *trc = fb_color_trc_hlg; + *matrix = fb_col_spc_b_t2020_ncl; + return true; + } + + if (name.contains(QStringLiteral("2020"))) { + *primaries = fb_color_primaries_bt2020; + *trc = fb_color_trc_bt709; + *matrix = fb_col_spc_b_t2020_ncl; + return true; + } + + if (name.contains(QStringLiteral("p3"))) { + *primaries = fb_color_primaries_smpte432; + *trc = fb_color_trc_srgb; + *matrix = fb_col_spc_b_t709; + return true; + } + + if (name.contains(QStringLiteral("srgb"))) { + *primaries = fb_color_primaries_bt709; + *trc = fb_color_trc_srgb; + *matrix = fb_col_spc_b_t709; + return true; + } + + if (name.contains(QStringLiteral("pal"))) { + *primaries = fb_color_primaries_bt470bg; + *trc = fb_color_trc_gamma28; + *matrix = fb_col_spc_b_t470_bg; + return true; + } + + if (name.contains(QStringLiteral("ntsc"))) { + *primaries = fb_color_primaries_smpte170m; + *trc = fb_color_trc_smpte170m; + *matrix = fb_col_spc_smpt_e170_m; + return true; + } + + if (name.contains(QStringLiteral("1886")) || + name.contains(QStringLiteral("709"))) { + *primaries = fb_color_primaries_bt709; + *trc = fb_color_trc_bt709; + *matrix = fb_col_spc_b_t709; + return true; + } + + return false; +} + QStringList FFmpegEncoder::get_pixel_formats_for_codec(ExportCodec::Codec c) const { QStringList pix_fmts; @@ -187,6 +254,19 @@ bool FFmpegEncoder::open() 1 : 0; + // Derive explicit nclc tags (HDR etc.) from the export colorspace; + // the bridge falls back to the legacy sRGB/Rec.709 logic when these + // are unspecified + int color_primaries = fb_color_primaries_unspec; + int color_trc = fb_color_trc_unspec; + int color_matrix = fb_col_spc_unspec; + get_color_tags_for_colorspace(params().color_transform().output(), + &color_primaries, &color_trc, + &color_matrix); + config.video_color_primaries = color_primaries; + config.video_color_trc = color_trc; + config.video_colorspace = color_matrix; + // Custom options (skip Olive-internal keys) for (auto i = params().video_opts().begin(); i != params().video_opts().end(); i++) { diff --git a/app/codec/ffmpeg/ffmpegencoder.h b/app/codec/ffmpeg/ffmpegencoder.h index 28120008f..f46a7a046 100644 --- a/app/codec/ffmpeg/ffmpegencoder.h +++ b/app/codec/ffmpeg/ffmpegencoder.h @@ -66,6 +66,18 @@ public: return video_conversion_fmt_; } + /** + * @brief Derives nclc color tags from an output colorspace name + * + * Extracted for testability. Returns true when the name maps to + * explicit tags (PQ/HLG/BT.2020, sRGB, P3, Rec.601, Rec.709); returns + * false for unknown names, in which case the bridge's legacy + * Rec.709/sRGB inference applies. + */ + static bool get_color_tags_for_colorspace(const QString &colorspace, + int *primaries, int *trc, + int *matrix); + private: /** * @brief Copy the last error message from the bridge into the encoder error state diff --git a/app/config/config.cpp b/app/config/config.cpp index e29a1d5ee..5848aaafa 100644 --- a/app/config/config.cpp +++ b/app/config/config.cpp @@ -78,6 +78,8 @@ void Config::set_defaults() set_entry_internal(QStringLiteral("HoverFocus"), NodeValue::k_boolean, false); set_entry_internal(QStringLiteral("AudioScrubbing"), NodeValue::k_boolean, true); + set_entry_internal(QStringLiteral("WaveformRgbParade"), NodeValue::k_boolean, + false); set_entry_internal(QStringLiteral("AutorecoveryEnabled"), NodeValue::k_boolean, true); set_entry_internal(QStringLiteral("AutorecoveryInterval"), NodeValue::k_int, diff --git a/app/node/color/CMakeLists.txt b/app/node/color/CMakeLists.txt index fd4eb2c85..1740dcc90 100644 --- a/app/node/color/CMakeLists.txt +++ b/app/node/color/CMakeLists.txt @@ -18,8 +18,10 @@ add_subdirectory(colormanager) add_subdirectory(displaytransform) add_subdirectory(ociobase) add_subdirectory(ociogradingtransformlinear) +add_subdirectory(ociogradingtransformlog) add_subdirectory(ociolut) add_subdirectory(threewaycolor) +add_subdirectory(whitebalance) set(OLIVE_SOURCES ${OLIVE_SOURCES} diff --git a/app/node/color/colormanager/colormanager.cpp b/app/node/color/colormanager/colormanager.cpp index fc162c57c..59dcce7c3 100644 --- a/app/node/color/colormanager/colormanager.cpp +++ b/app/node/color/colormanager/colormanager.cpp @@ -175,6 +175,53 @@ void ColorManager::set_default_input_color_space(const QString &s) project()->set_default_input_color_space(s); } +QString ColorManager::get_colorspace_for_ffmpeg_tags(int primaries, + int trc) const +{ + // FFmpeg AVColorPrimaries/AVColorTransferCharacteristic values mapped to + // candidate colorspace names, in order of preference + struct TagMapping { + int primaries; + int trc; + const char *candidates[3]; + }; + + static const TagMapping k_tag_mappings[] = { + { 1, 1, { "Rec.709 OETF", "Rec.709", "BT.709" } }, + { 1, 13, { "sRGB OETF", "sRGB", nullptr } }, + { 6, 6, { "Rec.601 OETF (NTSC)", "Rec.601 NTSC", nullptr } }, + { 5, 5, { "Rec.601 OETF (PAL)", "Rec.601 PAL", nullptr } }, + { 5, 6, { "Rec.601 OETF (PAL)", "Rec.601 PAL", nullptr } }, + { 9, 16, { "Rec.2020 PQ", "BT.2020 PQ", "ST 2084 PQ" } }, + { 9, 18, { "Rec.2020 HLG", "BT.2020 HLG", "HLG" } }, + { 9, 1, { "Rec.2020", "BT.2020", nullptr } }, + { 9, 14, { "Rec.2020", "BT.2020", nullptr } }, + { 9, 15, { "Rec.2020", "BT.2020", nullptr } }, + }; + + // 0 = unset, 2 = AVCOL_PRI/TRC_UNSPECIFIED + if (primaries == 0 || primaries == 2 || trc == 0 || trc == 2) { + return QString(); + } + + const QStringList available = list_available_colorspaces(); + + for (const TagMapping &mapping : k_tag_mappings) { + if (mapping.primaries == primaries && mapping.trc == trc) { + for (const char *candidate : mapping.candidates) { + if (candidate && + available.contains(QString::fromUtf8(candidate))) { + return QString::fromUtf8(candidate); + } + } + // Known tag pair, but the config has no matching colorspace + return QString(); + } + } + + return QString(); +} + QString ColorManager::get_reference_color_space() const { return project()->get_color_reference_space(); diff --git a/app/node/color/colormanager/colormanager.h b/app/node/color/colormanager/colormanager.h index e2c82d24b..e1670b39f 100644 --- a/app/node/color/colormanager/colormanager.h +++ b/app/node/color/colormanager/colormanager.h @@ -65,6 +65,16 @@ public: QString get_default_input_color_space() const; + /** + * @brief Auto-detects an input colorspace from media color tags + * + * Maps raw FFmpeg color primaries/transfer values (as exposed on + * VideoParams) to a colorspace of the active OCIO config. Returns an + * empty string when the tags are unknown or the config has no matching + * colorspace, in which case the default input colorspace applies. + */ + QString get_colorspace_for_ffmpeg_tags(int primaries, int trc) const; + void set_default_input_color_space(const QString &s); QString get_reference_color_space() const; diff --git a/app/node/color/ociogradingtransformlog/CMakeLists.txt b/app/node/color/ociogradingtransformlog/CMakeLists.txt new file mode 100644 index 000000000..57ace5e47 --- /dev/null +++ b/app/node/color/ociogradingtransformlog/CMakeLists.txt @@ -0,0 +1,23 @@ +# Olive - Non-Linear Video Editor +# Copyright (C) 2022 Olive Team +# Modifications Copyright (C) 2026 Oak Team +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set(OLIVE_SOURCES + ${OLIVE_SOURCES} + node/color/ociogradingtransformlog/ociogradingtransformlog.cpp + node/color/ociogradingtransformlog/ociogradingtransformlog.h + PARENT_SCOPE +) diff --git a/app/node/color/ociogradingtransformlog/ociogradingtransformlog.cpp b/app/node/color/ociogradingtransformlog/ociogradingtransformlog.cpp new file mode 100644 index 000000000..432a9c3e3 --- /dev/null +++ b/app/node/color/ociogradingtransformlog/ociogradingtransformlog.cpp @@ -0,0 +1,302 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "ociogradingtransformlog.h" + +#include + +#include "common/ocioutils.h" +#include "node/project.h" +#include "render/colorprocessor.h" +#include "widget/slider/floatslider.h" + +namespace olive +{ + +// These ids double as the OCIO GPU uniform names for the dynamic +// GradingPrimaryTransform; do not rename them. OCIO's log style maps the +// classic wheels as: brightness = lift, contrast = gain, gamma = gamma. +const QString OCIOGradingTransformLogNode::k_lift_input = + QStringLiteral("ocio_grading_primary_brightness"); +const QString OCIOGradingTransformLogNode::k_gain_input = + QStringLiteral("ocio_grading_primary_contrast"); +const QString OCIOGradingTransformLogNode::k_gamma_input = + QStringLiteral("ocio_grading_primary_gamma"); +const QString OCIOGradingTransformLogNode::k_saturation_input = + QStringLiteral("ocio_grading_primary_saturation"); +const QString OCIOGradingTransformLogNode::k_pivot_input = + QStringLiteral("ocio_grading_primary_pivot"); +const QString OCIOGradingTransformLogNode::k_clamp_black_enable_input = + QStringLiteral("clamp_black_enable_in"); +const QString OCIOGradingTransformLogNode::k_clamp_black_input = + QStringLiteral("ocio_grading_primary_clampBlack"); +const QString OCIOGradingTransformLogNode::k_clamp_white_enable_input = + QStringLiteral("clamp_white_enable_in"); +const QString OCIOGradingTransformLogNode::k_clamp_white_input = + QStringLiteral("ocio_grading_primary_clampWhite"); + +#define super OCIOBaseNode + +OCIOGradingTransformLogNode::OCIOGradingTransformLogNode() +{ + add_input(k_lift_input, NodeValue::k_vec4, QVector4D{ 0.0, 0.0, 0.0, 0.0 }); + set_input_property(k_lift_input, QStringLiteral("base"), 0.01); + set_vec4_input_colors(k_lift_input); + + add_input(k_gain_input, NodeValue::k_vec4, QVector4D{ 1.0, 1.0, 1.0, 1.0 }); + set_input_property(k_gain_input, QStringLiteral("base"), 0.01); + set_vec4_input_colors(k_gain_input); + + add_input(k_gamma_input, NodeValue::k_vec4, QVector4D{ 1.0, 1.0, 1.0, 1.0 }); + set_input_property(k_gamma_input, QStringLiteral("base"), 0.01); + set_vec4_input_colors(k_gamma_input); + + add_input(k_saturation_input, NodeValue::k_float, 1.0); + set_input_property(k_saturation_input, QStringLiteral("view"), + FloatSlider::k_percentage); + set_input_property(k_saturation_input, QStringLiteral("min"), 0.0); + + add_input(k_pivot_input, NodeValue::k_float, + -0.2); // Default for GRADING_LOG listed in ocio::GradingPrimary + set_input_property(k_pivot_input, QStringLiteral("base"), 0.01); + + add_input(k_clamp_black_enable_input, NodeValue::k_boolean, false); + + add_input(k_clamp_black_input, NodeValue::k_float, 0.0); + set_input_property(k_clamp_black_input, QStringLiteral("enabled"), + get_standard_value(k_clamp_black_enable_input).toBool()); + set_input_property(k_clamp_black_input, QStringLiteral("base"), 0.01); + + add_input(k_clamp_white_enable_input, NodeValue::k_boolean, false); + + add_input(k_clamp_white_input, NodeValue::k_float, 1.0); + set_input_property(k_clamp_white_input, QStringLiteral("enabled"), + get_standard_value(k_clamp_white_enable_input).toBool()); + set_input_property(k_clamp_white_input, QStringLiteral("base"), 0.01); + + // Constrain the white clamp minimum to just above the (static) black clamp + // as per ocio::GradingPrimary::validate. When the black clamp is keyframed + // or connected, Value() enforces the invariant per frame instead. + update_clamp_white_minimum(); +} + +QString OCIOGradingTransformLogNode::name() const +{ + return tr("OCIO Color Grading (Log)"); +} + +QString OCIOGradingTransformLogNode::id() const +{ + return QStringLiteral("org.olivevideoeditor.Olive.ociogradingtransformlog"); +} + +QVector OCIOGradingTransformLogNode::category() const +{ + return { k_category_color }; +} + +QString OCIOGradingTransformLogNode::description() const +{ + return tr("Lift/gamma/gain color grading using OpenColorIO."); +} + +void OCIOGradingTransformLogNode::retranslate() +{ + super::retranslate(); + + set_input_name(k_texture_input, tr("Input")); + set_input_name(k_lift_input, tr("Lift")); + set_input_name(k_gain_input, tr("Gain")); + set_input_name(k_gamma_input, tr("Gamma")); + set_input_name(k_saturation_input, tr("Saturation")); + set_input_name(k_pivot_input, tr("Pivot")); + set_input_name(k_clamp_black_enable_input, tr("Enable Black Clamp")); + set_input_name(k_clamp_black_input, tr("Black Clamp")); + set_input_name(k_clamp_white_enable_input, tr("Enable White Clamp")); + set_input_name(k_clamp_white_input, tr("White Clamp")); +} + +void OCIOGradingTransformLogNode::InputValueChangedEvent(const QString &input, + int element) +{ + Q_UNUSED(element); + + if (input == k_clamp_white_enable_input) { + set_input_property(k_clamp_white_input, QStringLiteral("enabled"), + get_standard_value(k_clamp_white_enable_input).toBool()); + } else if (input == k_clamp_black_enable_input) { + set_input_property(k_clamp_black_input, QStringLiteral("enabled"), + get_standard_value(k_clamp_black_enable_input).toBool()); + } else if (input == k_clamp_black_input) { + // Ensure the white clamp is always greater than the black clamp as per + // ocio::GradingPrimary::validate + update_clamp_white_minimum(); + } + + generate_processor(); +} + +void OCIOGradingTransformLogNode::InputConnectedEvent(const QString &input, + int element, Node *output) +{ + super::InputConnectedEvent(input, element, output); + + if (input == k_clamp_black_input) { + update_clamp_white_minimum(); + } +} + +void OCIOGradingTransformLogNode::InputDisconnectedEvent(const QString &input, + int element, + Node *output) +{ + super::InputDisconnectedEvent(input, element, output); + + if (input == k_clamp_black_input) { + update_clamp_white_minimum(); + } +} + +void OCIOGradingTransformLogNode::update_clamp_white_minimum() +{ + // A static UI minimum cannot follow an animated black clamp; for keyframed + // or connected values the white>black invariant is enforced per frame in + // Value() instead + if (is_input_keyframing(k_clamp_black_input) || + is_input_connected(k_clamp_black_input)) { + return; + } + + set_input_property(k_clamp_white_input, QStringLiteral("min"), + get_standard_value(k_clamp_black_input).toDouble() + 0.000001); +} + +void OCIOGradingTransformLogNode::generate_processor() +{ + if (manager()) { + ocio::GradingPrimaryTransformRcPtr gp = + ocio::GradingPrimaryTransform::Create(ocio::GRADING_LOG); + gp->makeDynamic(); + gp->setDirection(ocio::TransformDirection::TRANSFORM_DIR_FORWARD); + + try { + set_processor(ColorProcessor::create( + manager()->get_config()->getProcessor(gp))); + } catch (const ocio::Exception &e) { + std::cerr << std::endl << e.what() << std::endl; + } + } +} + +void OCIOGradingTransformLogNode::value(const NodeValueRow &value, + const NodeGlobals &globals, + NodeValueTable *table) const +{ + if (TexturePtr tex = value[k_texture_input].to_texture()) { + if (processor()) { + ColorTransformJob job(value); + + job.set_color_processor(processor()); + job.set_input_texture(value[k_texture_input]); + + const int master_channel = 0; + const int red_channel = 1; + const int green_channel = 2; + const int blue_channel = 3; + + // OCIO expects vec3s on the GPU but RGBMs (master + RGB) on the + // CPU; the per-style master combination below mirrors + // ocio::GradingPrimary. Lift is additive, gain/gamma multiply. + QVector4D lift = value[k_lift_input].to_vec4(); + lift[red_channel] += lift[master_channel]; + lift[green_channel] += lift[master_channel]; + lift[blue_channel] += lift[master_channel]; + job.insert(k_lift_input, + NodeValue(NodeValue::k_vec3, + QVector3D(lift[red_channel], lift[green_channel], + lift[blue_channel]))); + + QVector4D gain = value[k_gain_input].to_vec4(); + gain[red_channel] *= gain[master_channel]; + gain[green_channel] *= gain[master_channel]; + gain[blue_channel] *= gain[master_channel]; + job.insert(k_gain_input, + NodeValue(NodeValue::k_vec3, + QVector3D(gain[red_channel], gain[green_channel], + gain[blue_channel]))); + + QVector4D gamma = value[k_gamma_input].to_vec4(); + gamma[red_channel] *= gamma[master_channel]; + gamma[green_channel] *= gamma[master_channel]; + gamma[blue_channel] *= gamma[master_channel]; + job.insert(k_gamma_input, + NodeValue(NodeValue::k_vec3, + QVector3D(gamma[red_channel], + gamma[green_channel], + gamma[blue_channel]))); + + if (!value[k_clamp_black_enable_input].to_bool()) { + job.insert(k_clamp_black_input, + NodeValue(NodeValue::k_float, + ocio::GradingPrimary::NoClampBlack())); + } + + if (!value[k_clamp_white_enable_input].to_bool()) { + job.insert(k_clamp_white_input, + NodeValue(NodeValue::k_float, + ocio::GradingPrimary::NoClampWhite())); + } + + if (value[k_clamp_black_enable_input].to_bool() && + value[k_clamp_white_enable_input].to_bool()) { + // ocio::GradingPrimary::validate requires the white clamp to be + // greater than the black clamp. Keyframed or connected values + // can violate this at arbitrary times, so enforce the invariant + // per frame here. + const double clamp_black = value[k_clamp_black_input].to_double(); + const double clamp_white = value[k_clamp_white_input].to_double(); + if (clamp_white <= clamp_black) { + job.insert(k_clamp_white_input, + NodeValue(NodeValue::k_float, + clamp_black + 0.000001)); + } + } + + table->push(NodeValue::k_texture, tex->to_job(job), this); + } + } +} + +void OCIOGradingTransformLogNode::config_changed() +{ + generate_processor(); +} + +void OCIOGradingTransformLogNode::set_vec4_input_colors(const QString &input) +{ + set_input_property(input, QStringLiteral("color0"), + QColor(192, 192, 192).name()); + set_input_property(input, QStringLiteral("color1"), QColor(255, 0, 0).name()); + set_input_property(input, QStringLiteral("color2"), QColor(0, 255, 0).name()); + set_input_property(input, QStringLiteral("color3"), QColor(0, 0, 255).name()); +} + +} diff --git a/app/node/color/ociogradingtransformlog/ociogradingtransformlog.h b/app/node/color/ociogradingtransformlog/ociogradingtransformlog.h new file mode 100644 index 000000000..d18ce2fa5 --- /dev/null +++ b/app/node/color/ociogradingtransformlog/ociogradingtransformlog.h @@ -0,0 +1,91 @@ +/*** + + Olive - Non-Linear Video Editor + Copyright (C) 2022 Olive Team + Modifications Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_OCIOGRADINGTRANSFORMLOGNODE_H +#define OAK_OCIOGRADINGTRANSFORMLOGNODE_H + +#include "node/color/ociobase/ociobase.h" +#include "render/colorprocessor.h" + +namespace olive +{ + +/** + * @brief Lift/gamma/gain grading node built on ocio::GRADING_LOG + * + * Mirrors OCIOGradingTransformLinearNode for the log grading style. OCIO's + * log-style GPU uniforms map to the classic wheels as: brightness = lift, + * contrast = gain, gamma = gamma. + */ +class OCIOGradingTransformLogNode : public OCIOBaseNode { + Q_OBJECT +public: + OCIOGradingTransformLogNode(); + + NODE_DEFAULT_FUNCTIONS(OCIOGradingTransformLogNode) + + virtual QString name() const override; + virtual QString id() const override; + virtual QVector category() const override; + virtual QString description() const override; + + virtual void retranslate() override; + virtual void InputValueChangedEvent(const QString &input, + int element) override; + virtual void InputConnectedEvent(const QString &input, int element, + Node *output) override; + virtual void InputDisconnectedEvent(const QString &input, int element, + Node *output) override; + void generate_processor(); + + virtual void value(const NodeValueRow &value, const NodeGlobals &globals, + NodeValueTable *table) const override; + + static const QString k_lift_input; + static const QString k_gain_input; + static const QString k_gamma_input; + static const QString k_saturation_input; + static const QString k_pivot_input; + static const QString k_clamp_black_enable_input; + static const QString k_clamp_black_input; + static const QString k_clamp_white_enable_input; + static const QString k_clamp_white_input; + +protected slots: + virtual void config_changed() override; + +private: + void set_vec4_input_colors(const QString &input); + + /** + * @brief Constrains the white clamp UI minimum to just above the black + * clamp, as required by ocio::GradingPrimary::validate + * + * Only applies while the black clamp is a static value; when it is + * keyframed or connected the invariant is enforced per frame in Value() + * instead. + */ + void update_clamp_white_minimum(); +}; + +} // olive + +#endif diff --git a/app/node/color/ociolut/ociolut.cpp b/app/node/color/ociolut/ociolut.cpp index a2b14d74a..8bcf35347 100644 --- a/app/node/color/ociolut/ociolut.cpp +++ b/app/node/color/ociolut/ociolut.cpp @@ -77,11 +77,13 @@ OCIOLutNode::OCIOLutNode() { add_input(k_file_input, NodeValue::k_file, QString(), InputFlags(k_input_flag_not_keyframable | k_input_flag_not_connectable)); - set_input_property( - k_file_input, QStringLiteral("filter"), - tr("LUT Files (*.cube *.3dl);;Cube LUT (*.cube);;3DL LUT (*.3dl);;All Files (*)")); + const QString all_luts = + QStringLiteral("*.") + + LUTLibrary::supported_extensions().join(QStringLiteral(" *.")); + set_input_property(k_file_input, QStringLiteral("filter"), + tr("LUT Files (%1);;All Files (*)").arg(all_luts)); set_input_property(k_file_input, QStringLiteral("placeholder"), - tr("Select a .cube or .3dl LUT file")); + tr("Select a LUT file")); // Allow the UI to offer the global LUT library for this input set_input_property(k_file_input, QStringLiteral("lut_library"), true); @@ -257,10 +259,8 @@ bool OCIOLutNode::create_processor_from_inputs() const last_path_.clear(); last_direction_ = -1; processor_dirty_ = false; - set_last_error( - tr("OCIO LUT: unsupported LUT file extension (expected .cube or " - ".3dl): %1") - .arg(path)); + set_last_error(tr("OCIO LUT: unsupported LUT file extension: %1") + .arg(path)); return false; } diff --git a/app/node/color/whitebalance/CMakeLists.txt b/app/node/color/whitebalance/CMakeLists.txt new file mode 100644 index 000000000..006e8df15 --- /dev/null +++ b/app/node/color/whitebalance/CMakeLists.txt @@ -0,0 +1,22 @@ +# Oak - Non-Linear Video Editor +# Copyright (C) 2026 Oak Team +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +set(OLIVE_SOURCES + ${OLIVE_SOURCES} + node/color/whitebalance/whitebalance.cpp + node/color/whitebalance/whitebalance.h + PARENT_SCOPE +) diff --git a/app/node/color/whitebalance/whitebalance.cpp b/app/node/color/whitebalance/whitebalance.cpp new file mode 100644 index 000000000..90e3e7e10 --- /dev/null +++ b/app/node/color/whitebalance/whitebalance.cpp @@ -0,0 +1,156 @@ +/*** + + Oak - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#include "whitebalance.h" + +#include + +#include "common/filefunctions.h" +#include "render/job/shaderjob.h" +#include "render/texture.h" +#include "widget/slider/floatslider.h" + +namespace olive +{ + +#define super Node + +const QString WhiteBalanceNode::k_texture_input = QStringLiteral("tex_in"); +const QString WhiteBalanceNode::k_temperature_input = + QStringLiteral("temperature_in"); +const QString WhiteBalanceNode::k_tint_input = QStringLiteral("tint_in"); +const QString WhiteBalanceNode::k_gain_input = QStringLiteral("wb_gain_in"); + +WhiteBalanceNode::WhiteBalanceNode() +{ + add_input(k_texture_input, NodeValue::k_texture, + InputFlags(k_input_flag_not_keyframable)); + + add_input(k_temperature_input, NodeValue::k_float, 6500.0); + set_input_property(k_temperature_input, QStringLiteral("min"), 1000.0); + set_input_property(k_temperature_input, QStringLiteral("max"), 40000.0); + set_input_property(k_temperature_input, QStringLiteral("view"), + FloatSlider::k_normal); + + add_input(k_tint_input, NodeValue::k_float, 0.0); + set_input_property(k_tint_input, QStringLiteral("min"), -1.0); + set_input_property(k_tint_input, QStringLiteral("max"), 1.0); + set_input_property(k_tint_input, QStringLiteral("base"), 0.01); + + set_effect_input(k_texture_input); + set_flag(k_video_effect); +} + +QString WhiteBalanceNode::name() const +{ + return tr("White Balance"); +} + +QString WhiteBalanceNode::id() const +{ + return QStringLiteral("org.olivevideoeditor.Olive.whitebalance"); +} + +QVector WhiteBalanceNode::category() const +{ + return { k_category_color }; +} + +QString WhiteBalanceNode::description() const +{ + return tr("Adjust white balance by color temperature and tint."); +} + +void WhiteBalanceNode::retranslate() +{ + super::retranslate(); + + set_input_name(k_texture_input, tr("Input")); + set_input_name(k_temperature_input, tr("Temperature (K)")); + set_input_name(k_tint_input, tr("Tint")); +} + +ShaderCode WhiteBalanceNode::get_shader_code(const ShaderRequest &request) const +{ + Q_UNUSED(request) + return ShaderCode( + FileFunctions::read_file_as_string(":/shaders/whitebalance.frag")); +} + +void WhiteBalanceNode::value(const NodeValueRow &value, + const NodeGlobals &globals, + NodeValueTable *table) const +{ + Q_UNUSED(globals) + + if (TexturePtr tex = value[k_texture_input].to_texture()) { + ShaderJob job(value); + + const QVector3D gain = get_gain_for_temperature( + value[k_temperature_input].to_double(), + value[k_tint_input].to_double()); + job.insert(k_gain_input, NodeValue(NodeValue::k_vec3, gain)); + + table->push(NodeValue::k_texture, tex->to_job(job), this); + } +} + +QVector3D WhiteBalanceNode::get_gain_for_temperature(double kelvin, double tint) +{ + // Tanner Helland blackbody approximation (1000K-40000K), returning + // 0-255 per channel + kelvin = qBound(1000.0, kelvin, 40000.0); + const double t = kelvin / 100.0; + + double red; + if (t <= 66.0) { + red = 255.0; + } else { + red = 329.698727446 * qPow(t - 60.0, -0.1332047592); + } + + double green; + if (t <= 66.0) { + green = 99.4708025861 * qLn(t) - 161.1195681661; + } else { + green = 288.1221695283 * qPow(t - 60.0, -0.0755148492); + } + + double blue; + if (t >= 66.0) { + blue = 255.0; + } else if (t <= 19.0) { + blue = 0.0; + } else { + blue = 138.5177312231 * qLn(t - 10.0) - 305.0447927307; + } + + // Normalize to the green channel so temperature shifts do not change + // exposure, then let tint move along the green-magenta axis + red /= green; + blue /= green; + green = 1.0; + + const double tint_gain = qBound(0.0, 1.0 + tint, 2.0); + + return QVector3D(float(red), float(green * tint_gain), float(blue)); +} + +} diff --git a/app/node/color/whitebalance/whitebalance.h b/app/node/color/whitebalance/whitebalance.h new file mode 100644 index 000000000..23fd11a19 --- /dev/null +++ b/app/node/color/whitebalance/whitebalance.h @@ -0,0 +1,74 @@ +/*** + + Oak - Non-Linear Video Editor + Copyright (C) 2026 Oak Team + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +***/ + +#ifndef OAK_WHITEBALANCENODE_H +#define OAK_WHITEBALANCENODE_H + +#include + +#include "node/node.h" + +namespace olive +{ + +/** + * @brief White balance correction by color temperature and tint + * + * Converts a scene illuminant temperature (in Kelvin) into per-channel RGB + * gains using the Tanner Helland blackbody approximation, normalized so the + * green channel is preserved (no exposure shift). Tint shifts the image + * along the green-magenta axis. + */ +class WhiteBalanceNode : public Node { + Q_OBJECT +public: + WhiteBalanceNode(); + + NODE_DEFAULT_FUNCTIONS(WhiteBalanceNode) + + virtual QString name() const override; + virtual QString id() const override; + virtual QVector category() const override; + virtual QString description() const override; + + virtual void retranslate() override; + + virtual ShaderCode get_shader_code(const ShaderRequest &request) const override; + + virtual void value(const NodeValueRow &value, const NodeGlobals &globals, + NodeValueTable *table) const override; + + /** + * @brief RGB gains for a given illuminant temperature and tint + * + * Extracted for testability. Kelvin is clamped to [1000, 40000]; the + * result is normalized so the green channel gain is 1.0 at tint 0. + */ + static QVector3D get_gain_for_temperature(double kelvin, double tint); + + static const QString k_texture_input; + static const QString k_temperature_input; + static const QString k_tint_input; + static const QString k_gain_input; +}; + +} // olive + +#endif diff --git a/app/node/factory.cpp b/app/node/factory.cpp index 28e672162..1590ad220 100644 --- a/app/node/factory.cpp +++ b/app/node/factory.cpp @@ -33,8 +33,10 @@ #include "block/transition/diptocolor/diptocolortransition.h" #include "color/displaytransform/displaytransform.h" #include "color/ociogradingtransformlinear/ociogradingtransformlinear.h" +#include "color/ociogradingtransformlog/ociogradingtransformlog.h" #include "color/ociolut/ociolut.h" #include "color/threewaycolor/threewaycolor.h" +#include "color/whitebalance/whitebalance.h" #include "common/current.h" #include "distort/cornerpin/cornerpindistortnode.h" #include "distort/crop/cropdistortnode.h" @@ -375,6 +377,10 @@ Node *NodeFactory::create_from_factory_index(const NodeFactory::InternalID &id) return new DisplayTransformNode(); case k_ocio_grading_transform_linear: return new OCIOGradingTransformLinearNode(); + case k_ocio_grading_transform_log: + return new OCIOGradingTransformLogNode(); + case k_white_balance: + return new WhiteBalanceNode(); case k_ocio_lut: return new OCIOLutNode(); case k_three_way_color: diff --git a/app/node/factory.h b/app/node/factory.h index 50fb72bce..a7b6de0d4 100644 --- a/app/node/factory.h +++ b/app/node/factory.h @@ -84,6 +84,8 @@ public: k_tile_distort, k_swirl_distort, k_multicam_node, + k_ocio_grading_transform_log, + k_white_balance, // Count value k_internal_node_count diff --git a/app/node/project/footage/footage.cpp b/app/node/project/footage/footage.cpp index 0dd0c04ce..7e7b9348f 100644 --- a/app/node/project/footage/footage.cpp +++ b/app/node/project/footage/footage.cpp @@ -126,11 +126,20 @@ Rational Footage::verify_length_internal(Track::Type type) const QString Footage::get_colorspace_to_use(const VideoParams ¶ms) const { - if (params.colorspace().isEmpty()) { - return project()->color_manager()->get_default_input_color_space(); - } else { + if (!params.colorspace().isEmpty()) { + // The user explicitly set this stream's colorspace return params.colorspace(); } + + // No override: try auto-detecting from the media's color tags + const QString detected = + project()->color_manager()->get_colorspace_for_ffmpeg_tags( + params.color_primaries(), params.color_transfer()); + if (!detected.isEmpty()) { + return detected; + } + + return project()->color_manager()->get_default_input_color_space(); } void Footage::clear() diff --git a/app/render/lutlibrary.cpp b/app/render/lutlibrary.cpp index 54e96b1da..b8e53e83a 100644 --- a/app/render/lutlibrary.cpp +++ b/app/render/lutlibrary.cpp @@ -29,6 +29,17 @@ namespace olive { +const QStringList &LUTLibrary::supported_extensions() +{ + // LUT formats OCIO FileTransform can load + static const QStringList extensions = { + QStringLiteral("cube"), QStringLiteral("3dl"), QStringLiteral("spi1d"), + QStringLiteral("spi3d"), QStringLiteral("spimtx"), QStringLiteral("csp"), + QStringLiteral("clf"), QStringLiteral("ctf"), QStringLiteral("cub"), + }; + return extensions; +} + bool LUTLibrary::is_supported_extension(const QString &suffix) { QString s = suffix; @@ -36,8 +47,7 @@ bool LUTLibrary::is_supported_extension(const QString &suffix) s.remove(0, 1); } - const QString lower = s.toLower(); - return lower == QStringLiteral("cube") || lower == QStringLiteral("3dl"); + return supported_extensions().contains(s.toLower()); } QStringList LUTLibrary::get_directories() diff --git a/app/render/lutlibrary.h b/app/render/lutlibrary.h index f171b8fb4..fc339dc16 100644 --- a/app/render/lutlibrary.h +++ b/app/render/lutlibrary.h @@ -37,9 +37,16 @@ namespace olive */ class LUTLibrary { public: + /** + * @brief All LUT file extensions supported by the library + * + * Extensions OCIO FileTransform can load, lowercase, without the dot. + */ + static const QStringList &supported_extensions(); + /** * @brief Returns true if the given file suffix is a supported LUT - * extension (.cube or .3dl, case-insensitive, leading dot tolerated) + * extension (case-insensitive, leading dot tolerated) */ static bool is_supported_extension(const QString &suffix); diff --git a/app/render/videoparams.cpp b/app/render/videoparams.cpp index 1e5553522..080ba49be 100644 --- a/app/render/videoparams.cpp +++ b/app/render/videoparams.cpp @@ -419,6 +419,10 @@ void VideoParams::load(QXmlStreamReader *reader) } else if (reader->name() == QStringLiteral("colorrange")) { set_color_range( static_cast(reader->readElementText().toInt())); + } else if (reader->name() == QStringLiteral("colorprimaries")) { + set_color_primaries(reader->readElementText().toInt()); + } else if (reader->name() == QStringLiteral("colortransfer")) { + set_color_transfer(reader->readElementText().toInt()); } else { reader->skipCurrentElement(); } @@ -463,6 +467,10 @@ void VideoParams::save(QXmlStreamWriter *writer) const writer->writeTextElement(QStringLiteral("colorspace"), colorspace_); writer->writeTextElement(QStringLiteral("colorrange"), QString::number(color_range_)); + writer->writeTextElement(QStringLiteral("colorprimaries"), + QString::number(color_primaries_)); + writer->writeTextElement(QStringLiteral("colortransfer"), + QString::number(color_transfer_)); } } diff --git a/app/render/videoparams.h b/app/render/videoparams.h index 277cf2032..82b963970 100644 --- a/app/render/videoparams.h +++ b/app/render/videoparams.h @@ -378,6 +378,33 @@ public: color_range_ = color_range; } + /** + * @brief Color primaries/transfer as reported by the media + * + * Raw FFmpeg AVColorPrimaries/AVColorTransferCharacteristic values + * (0 = unset, 2 = unspecified). Used to auto-detect the input + * colorspace when no explicit colorspace has been set. + */ + int color_primaries() const + { + return color_primaries_; + } + + void set_color_primaries(int p) + { + color_primaries_ = p; + } + + int color_transfer() const + { + return color_transfer_; + } + + void set_color_transfer(int t) + { + color_transfer_ = t; + } + int64_t get_time_in_timebase_units(const Rational &time) const; void load(QXmlStreamReader *reader); @@ -425,6 +452,8 @@ private: float x_; float y_; ColorRange color_range_; + int color_primaries_ = 0; + int color_transfer_ = 0; }; } diff --git a/app/shaders/rgbwaveform.frag b/app/shaders/rgbwaveform.frag index cb3a00830..e895e930b 100644 --- a/app/shaders/rgbwaveform.frag +++ b/app/shaders/rgbwaveform.frag @@ -4,6 +4,7 @@ uniform vec2 viewport; uniform vec3 luma_coeffs; uniform float waveform_scale; +uniform float parade_mode; in vec2 ove_texcoord; out vec4 frag_color; @@ -16,23 +17,51 @@ void main(void) { vec4 cur_col = vec4(0.0); float ratio = 0.0; - for (int i = 0; float(i) < waveform_height; i++) { - ratio = float(i) / float(waveform_height - 1.0); - cur_col.rgb = texture( - ove_maintex, - vec2(ove_texcoord.x, ratio) - ).rgb; + if (parade_mode > 0.5) { + // RGB parade: three zones side by side, one channel per zone + float zone = floor(ove_texcoord.x * 3.0); + float zone_x = fract(ove_texcoord.x * 3.0); + vec3 zone_color = zone < 0.5 ? vec3(1.0, 0.0, 0.0) : + (zone < 1.5 ? vec3(0.0, 1.0, 0.0) : + vec3(0.0, 0.0, 1.0)); - cur_col.w = dot(cur_col.rgb, luma_coeffs); + for (int i = 0; float(i) < waveform_height; i++) { + ratio = float(i) / float(waveform_height - 1.0); + vec3 sample_rgb = texture( + ove_maintex, + vec2(zone_x, ratio) + ).rgb; - col += ( - step(vec4(ove_texcoord.y - quantisation), cur_col) * - step(cur_col, vec4(ove_texcoord.y + quantisation)) * - intensity) + - (step(1.0 - quantisation, ove_texcoord.y) * - step(vec4(1.0 - quantisation), cur_col) * intensity); + float channel = zone < 0.5 ? sample_rgb.r : + (zone < 1.5 ? sample_rgb.g : sample_rgb.b); + + float hit = step(ove_texcoord.y - quantisation, channel) * + step(channel, ove_texcoord.y + quantisation) * intensity; + hit += step(1.0 - quantisation, ove_texcoord.y) * + step(1.0 - quantisation, channel) * intensity; + + col.rgb += hit * zone_color; + } + } else { + for (int i = 0; float(i) < waveform_height; i++) { + ratio = float(i) / float(waveform_height - 1.0); + cur_col.rgb = texture( + ove_maintex, + vec2(ove_texcoord.x, ratio) + ).rgb; + + cur_col.w = dot(cur_col.rgb, luma_coeffs); + + col += ( + step(vec4(ove_texcoord.y - quantisation), cur_col) * + step(cur_col, vec4(ove_texcoord.y + quantisation)) * + intensity) + + (step(1.0 - quantisation, ove_texcoord.y) * + step(vec4(1.0 - quantisation), cur_col) * intensity); + } + + col.rgb += vec3(col.w); } - col.rgb += vec3(col.w); frag_color = vec4(col.rgb, 1.0); } diff --git a/app/shaders/whitebalance.frag b/app/shaders/whitebalance.frag new file mode 100644 index 000000000..b8d02d205 --- /dev/null +++ b/app/shaders/whitebalance.frag @@ -0,0 +1,15 @@ +uniform sampler2D tex_in; + +uniform vec3 wb_gain_in; + +in vec2 ove_texcoord; +out vec4 frag_color; + +void main(void) +{ + vec4 source = texture(tex_in, ove_texcoord); + + // Deliberately not clamped: white balance must also work on HDR/linear + // footage with values above 1.0 + frag_color = vec4(source.rgb * wb_gain_in, source.a); +} diff --git a/app/widget/scope/waveform/waveform.cpp b/app/widget/scope/waveform/waveform.cpp index 82d672cd6..c136facbe 100644 --- a/app/widget/scope/waveform/waveform.cpp +++ b/app/widget/scope/waveform/waveform.cpp @@ -22,6 +22,8 @@ #include "waveform.h" +#include +#include #include #include #include @@ -39,9 +41,27 @@ namespace olive WaveformScope::WaveformScope(QWidget *parent) : super(parent) + , parade_mode_(OAK_CONFIG("WaveformRgbParade").toBool()) { } +void WaveformScope::set_parade_mode(bool enabled) +{ + parade_mode_ = enabled; + OAK_CONFIG("WaveformRgbParade") = enabled; + update(); +} + +void WaveformScope::contextMenuEvent(QContextMenuEvent *event) +{ + QMenu menu(this); + QAction *parade = menu.addAction(tr("RGB Parade")); + parade->setCheckable(true); + parade->setChecked(parade_mode_); + connect(parade, &QAction::triggered, this, &WaveformScope::set_parade_mode); + menu.exec(event->globalPos()); +} + ShaderCode WaveformScope::generate_shader_code() { return ShaderCode( @@ -72,6 +92,10 @@ void WaveformScope::draw_scope(TexturePtr managed_tex, QVariant pipeline) job.insert(QStringLiteral("waveform_scale"), NodeValue(NodeValue::k_float, waveform_scale)); + // Overlay vs. RGB parade display + job.insert(QStringLiteral("parade_mode"), + NodeValue(NodeValue::k_float, parade_mode_ ? 1.0f : 0.0f)); + // Insert source texture job.insert(QStringLiteral("ove_maintex"), NodeValue(NodeValue::k_texture, @@ -156,26 +180,40 @@ void WaveformScope::draw_scope_software(QPainter &p, const QImage &image) continue; } - auto mark = [&](float value, int add_r, int add_g, int add_b) { + auto mark = [&](int x, float value, int add_r, int add_g, int add_b) { int scope_y = waveform_start_dim_y + int((1.0f - value) * waveform_dim_y); - if (scope_y < waveform_start_dim_y || + if (x < waveform_start_dim_x || x >= waveform_end_dim_x || + scope_y < waveform_start_dim_y || scope_y >= waveform_start_dim_y + waveform_dim_y) { return; } - QRgb *dst_line = - reinterpret_cast(buf.scanLine(scope_y)); - QRgb cur = dst_line[scope_x]; + QRgb *dst_line = reinterpret_cast(buf.scanLine(scope_y)); + QRgb cur = dst_line[x]; int nr = qMin(255, qRed(cur) + add_r); int ng = qMin(255, qGreen(cur) + add_g); int nb = qMin(255, qBlue(cur) + add_b); int na = qMax(qMax(nr, ng), nb); - dst_line[scope_x] = qRgba(nr, ng, nb, na); + dst_line[x] = qRgba(nr, ng, nb, na); }; - mark(r, 30, 0, 0); - mark(g, 0, 30, 0); - mark(b, 0, 0, 30); + if (parade_mode_) { + // RGB parade: each channel gets one third of the scope width + const float zone_x = float(sx) / float(src_w) / 3.0f; + mark(waveform_start_dim_x + + int((0.0f + zone_x) * waveform_dim_x), + r, 30, 0, 0); + mark(waveform_start_dim_x + + int((1.0f / 3.0f + zone_x) * waveform_dim_x), + g, 0, 30, 0); + mark(waveform_start_dim_x + + int((2.0f / 3.0f + zone_x) * waveform_dim_x), + b, 0, 0, 30); + } else { + mark(scope_x, r, 30, 0, 0); + mark(scope_x, g, 0, 30, 0); + mark(scope_x, b, 0, 0, 30); + } } } diff --git a/app/widget/scope/waveform/waveform.h b/app/widget/scope/waveform/waveform.h index 2b07132ca..6b2db09b6 100644 --- a/app/widget/scope/waveform/waveform.h +++ b/app/widget/scope/waveform/waveform.h @@ -34,12 +34,24 @@ public: MANAGEDDISPLAYWIDGET_DEFAULT_DESTRUCTOR(WaveformScope) + bool parade_mode() const + { + return parade_mode_; + } + + void set_parade_mode(bool enabled); + protected: virtual ShaderCode generate_shader_code() override; virtual void draw_scope(TexturePtr managed_tex, QVariant pipeline) override; virtual void draw_scope_software(QPainter &p, const QImage &image) override; + + virtual void contextMenuEvent(QContextMenuEvent *event) override; + +private: + bool parade_mode_; }; } diff --git a/docs/project-file-reference.md b/docs/project-file-reference.md index b4c858eb9..5fd3ea7b7 100644 --- a/docs/project-file-reference.md +++ b/docs/project-file-reference.md @@ -517,6 +517,8 @@ Serialized inside `` for inputs of type `kVideoParams`. 0|1 string int +int +int ``` - `format`: `PixelFormat::Format` integer (`U8`, `U10`, `U16`, `F16`, `F32`). @@ -527,6 +529,8 @@ Serialized inside `` for inputs of type `kVideoParams`. - `videotype`:`VideoParams::Type` 整数。 - `colorrange`: `VideoParams::ColorRange` integer (`0` = limited, `1` = full). - `colorrange`:`VideoParams::ColorRange` 整数(`0` 为 limited,`1` 为 full)。 +- `colorprimaries`, `colortransfer`: raw FFmpeg `AVColorPrimaries` / `AVColorTransferCharacteristic` values reported by the media (`0` = unset, `2` = unspecified). Used to auto-detect the input colorspace when `colorspace` is empty. + - `colorprimaries`、`colortransfer`:媒体上报的 FFmpeg 原始 `AVColorPrimaries` / `AVColorTransferCharacteristic` 值(`0` 为未设置,`2` 为未指定)。当 `colorspace` 为空时用于自动检测输入色彩空间。 --- diff --git a/ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h b/ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h index 04cabccf7..f3b6b9070 100644 --- a/ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h +++ b/ffmpeg_bridge/include/ffmpeg_bridge/ffmpeg_bridge.h @@ -149,6 +149,27 @@ typedef enum FBColorSpace { fb_col_spc_b_t2020_ncl = 9 } FBColorSpace; +/** Color primaries. Values mirror AVColorPrimaries (static_assert'ed). */ +typedef enum FBColorPrimaries { + fb_color_primaries_bt709 = 1, + fb_color_primaries_unspec = 2, + fb_color_primaries_bt470bg = 5, + fb_color_primaries_smpte170m = 6, + fb_color_primaries_bt2020 = 9, + fb_color_primaries_smpte432 = 12 +} FBColorPrimaries; + +/** Color transfer characteristics. Values mirror AVColorTransferCharacteristic. */ +typedef enum FBColorTrc { + fb_color_trc_bt709 = 1, + fb_color_trc_unspec = 2, + fb_color_trc_gamma28 = 5, + fb_color_trc_smpte170m = 6, + fb_color_trc_srgb = 13, + fb_color_trc_pq = 16, + fb_color_trc_hlg = 18 +} FBColorTrc; + /** Media types. Values mirror AVMediaType (static_assert'ed). */ typedef enum FBMediaType { fb_media_type_video = 0, @@ -322,6 +343,8 @@ typedef struct FBStreamInfo { int pixel_format; /* FBPixelFormat */ int field_order; /* FBFieldOrder */ int color_range; /* FBColorRange */ + int color_primaries; /* raw AVColorPrimaries value (2 = unspecified) */ + int color_trc; /* raw AVColorTransferCharacteristic value */ int sample_rate; int sample_format; /* FBSampleFormat */ @@ -536,6 +559,13 @@ typedef struct FBEncoderConfig { int64_t video_buffer_size; int video_threads; int video_color_srgb; /* non-zero: tag nclc as sRGB, else Rec.709 */ + /* Explicit nclc tags; when video_color_primaries is + * fb_color_primaries_unspec, tags are inferred from video_color_srgb + * instead. Values match the corresponding + * AVColorPrimaries/AVColorTransferCharacteristic/AVColorSpace constants. */ + int video_color_primaries; /* FBColorPrimaries */ + int video_color_trc; /* FBColorTrc */ + int video_colorspace; /* FBColorSpaceTag */ const char **video_opt_keys; const char **video_opt_values; int video_opt_count; diff --git a/ffmpeg_bridge/src/decoder.cpp b/ffmpeg_bridge/src/decoder.cpp index 315aea470..5d3120c2f 100644 --- a/ffmpeg_bridge/src/decoder.cpp +++ b/ffmpeg_bridge/src/decoder.cpp @@ -435,6 +435,8 @@ int fb_decoder_get_stream_info(const FBDecoder *decoder, FBStreamInfo *out) out->field_order = decoder->codec_ctx ? decoder->codec_ctx->field_order : AV_FIELD_UNKNOWN; out->color_range = par->color_range; + out->color_primaries = par->color_primaries; + out->color_trc = par->color_trc; out->sample_rate = par->sample_rate; out->sample_format = par->format; out->channel_layout_mask = fb::validate_stream_channel_layout_mask(s); diff --git a/ffmpeg_bridge/src/encoder.cpp b/ffmpeg_bridge/src/encoder.cpp index b1a023141..8d6b071f5 100644 --- a/ffmpeg_bridge/src/encoder.cpp +++ b/ffmpeg_bridge/src/encoder.cpp @@ -153,6 +153,9 @@ struct FBEncoder { int64_t video_buffer_size = 0; int video_threads = 0; int video_color_srgb = 0; + int video_color_primaries = fb_color_primaries_unspec; + int video_color_trc = fb_color_trc_unspec; + int video_colorspace = fb_col_spc_unspec; std::vector> video_opts; int audio_enabled = 0; @@ -251,6 +254,9 @@ FBEncoder *fb_encoder_create(const FBEncoderConfig *config) e->video_buffer_size = config->video_buffer_size; e->video_threads = config->video_threads; e->video_color_srgb = config->video_color_srgb; + e->video_color_primaries = config->video_color_primaries; + e->video_color_trc = config->video_color_trc; + e->video_colorspace = config->video_colorspace; for (int i = 0; i < config->video_opt_count; i++) { if (config->video_opt_keys[i] && config->video_opt_values[i]) { e->video_opts.emplace_back(config->video_opt_keys[i], @@ -868,7 +874,14 @@ bool FBEncoder::initialize_stream(AVMediaType type, AVStream **stream_ptr, } // nclc tags. See https://ffmpeg.org/doxygen/4.0/pixfmt_8h.html#ad384ee5a840bafd73daef08e6d9cafe7 - if (video_color_srgb) { + if (video_color_primaries != fb_color_primaries_unspec) { + // Explicit tags supplied (e.g. derived from the export colorspace) + codec_ctx->color_primaries = + static_cast(video_color_primaries); + codec_ctx->color_trc = + static_cast(video_color_trc); + codec_ctx->colorspace = static_cast(video_colorspace); + } else if (video_color_srgb) { codec_ctx->color_primaries = AVCOL_PRI_BT709; codec_ctx->color_trc = AVCOL_TRC_IEC61966_2_1; codec_ctx->colorspace = AVCOL_SPC_BT709; diff --git a/ffmpeg_bridge/src/internal.h b/ffmpeg_bridge/src/internal.h index 5ce09d92f..e14aaa78b 100644 --- a/ffmpeg_bridge/src/internal.h +++ b/ffmpeg_bridge/src/internal.h @@ -84,6 +84,21 @@ static_assert(fb_col_spc_smpt_e170_m == AVCOL_SPC_SMPTE170M, "colspace mismatch" static_assert(fb_col_spc_smpt_e240_m == AVCOL_SPC_SMPTE240M, "colspace mismatch"); static_assert(fb_col_spc_b_t2020_ncl == AVCOL_SPC_BT2020_NCL, "colspace mismatch"); +static_assert(fb_color_primaries_unspec == AVCOL_PRI_UNSPECIFIED, "primaries mismatch"); +static_assert(fb_color_primaries_bt709 == AVCOL_PRI_BT709, "primaries mismatch"); +static_assert(fb_color_primaries_bt470bg == AVCOL_PRI_BT470BG, "primaries mismatch"); +static_assert(fb_color_primaries_smpte170m == AVCOL_PRI_SMPTE170M, "primaries mismatch"); +static_assert(fb_color_primaries_bt2020 == AVCOL_PRI_BT2020, "primaries mismatch"); +static_assert(fb_color_primaries_smpte432 == AVCOL_PRI_SMPTE432, "primaries mismatch"); + +static_assert(fb_color_trc_unspec == AVCOL_TRC_UNSPECIFIED, "trc mismatch"); +static_assert(fb_color_trc_bt709 == AVCOL_TRC_BT709, "trc mismatch"); +static_assert(fb_color_trc_gamma28 == AVCOL_TRC_GAMMA28, "trc mismatch"); +static_assert(fb_color_trc_smpte170m == AVCOL_TRC_SMPTE170M, "trc mismatch"); +static_assert(fb_color_trc_srgb == AVCOL_TRC_IEC61966_2_1, "trc mismatch"); +static_assert(fb_color_trc_pq == AVCOL_TRC_SMPTE2084, "trc mismatch"); +static_assert(fb_color_trc_hlg == AVCOL_TRC_ARIB_STD_B67, "trc mismatch"); + static_assert(fb_media_type_video == AVMEDIA_TYPE_VIDEO, "mediatype mismatch"); static_assert(fb_media_type_audio == AVMEDIA_TYPE_AUDIO, "mediatype mismatch"); static_assert(fb_media_type_data == AVMEDIA_TYPE_DATA, "mediatype mismatch"); diff --git a/ffmpeg_bridge/src/probe.cpp b/ffmpeg_bridge/src/probe.cpp index e9727c502..94e8711b0 100644 --- a/ffmpeg_bridge/src/probe.cpp +++ b/ffmpeg_bridge/src/probe.cpp @@ -48,6 +48,8 @@ void fill_stream_info(const AVStream *s, int has_decoder, FBStreamInfo *out) out->pixel_format = fb::pix_fmt_from_av(AVPixelFormat(par->format)); out->field_order = fb_field_order_unknown; out->color_range = par->color_range; + out->color_primaries = par->color_primaries; + out->color_trc = par->color_trc; out->sample_rate = par->sample_rate; out->sample_format = par->format; out->channel_layout_mask = fb::validate_stream_channel_layout_mask(s); diff --git a/tests/gtest/codec_decoder_test.cpp b/tests/gtest/codec_decoder_test.cpp index 9f546f5ad..e30a67697 100644 --- a/tests/gtest/codec_decoder_test.cpp +++ b/tests/gtest/codec_decoder_test.cpp @@ -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 &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); +} diff --git a/tests/gtest/codec_ffmpegencoder_test.cpp b/tests/gtest/codec_ffmpegencoder_test.cpp index 7ed6bdea6..6147acd5a 100644 --- a/tests/gtest/codec_ffmpegencoder_test.cpp +++ b/tests/gtest/codec_ffmpegencoder_test.cpp @@ -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); +} diff --git a/tests/gtest/color_lut_test.cpp b/tests/gtest/color_lut_test.cpp index d898818f3..00787456b 100644 --- a/tests/gtest/color_lut_test.cpp +++ b/tests/gtest/color_lut_test.cpp @@ -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 = diff --git a/tests/gtest/footage_probe_test.cpp b/tests/gtest/footage_probe_test.cpp index 2a78bcdda..db7a864d9 100644 --- a/tests/gtest/footage_probe_test.cpp +++ b/tests/gtest/footage_probe_test.cpp @@ -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(texture->job()); diff --git a/tests/gtest/node_color_test.cpp b/tests/gtest/node_color_test.cpp index ff169886e..0f8c53840 100644 --- a/tests/gtest/node_color_test.cpp +++ b/tests/gtest/node_color_test.cpp @@ -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(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 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 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 // ----------------------------------------------------------------------------- diff --git a/tests/gtest/render_videoparams_test.cpp b/tests/gtest/render_videoparams_test.cpp index c80cdd12b..ac28a03e1 100644 --- a/tests/gtest/render_videoparams_test.cpp +++ b/tests/gtest/render_videoparams_test.cpp @@ -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); }