diff --git a/app/node/color/CMakeLists.txt b/app/node/color/CMakeLists.txt
index 96bd0db4f..37975cab4 100644
--- a/app/node/color/CMakeLists.txt
+++ b/app/node/color/CMakeLists.txt
@@ -18,6 +18,8 @@ add_subdirectory(colormanager)
add_subdirectory(displaytransform)
add_subdirectory(ociobase)
add_subdirectory(ociogradingtransformlinear)
+add_subdirectory(ociolut)
+add_subdirectory(threewaycolor)
set(OLIVE_SOURCES
${OLIVE_SOURCES}
diff --git a/app/node/color/ociolut/CMakeLists.txt b/app/node/color/ociolut/CMakeLists.txt
new file mode 100644
index 000000000..55a4ae8a2
--- /dev/null
+++ b/app/node/color/ociolut/CMakeLists.txt
@@ -0,0 +1,14 @@
+# Olive - 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.
+
+set(OLIVE_SOURCES
+ ${OLIVE_SOURCES}
+ node/color/ociolut/ociolut.cpp
+ node/color/ociolut/ociolut.h
+ PARENT_SCOPE
+)
diff --git a/app/node/color/ociolut/ociolut.cpp b/app/node/color/ociolut/ociolut.cpp
new file mode 100644
index 000000000..0fb2fae87
--- /dev/null
+++ b/app/node/color/ociolut/ociolut.cpp
@@ -0,0 +1,138 @@
+/***
+
+ 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 "ociolut.h"
+
+#include
+
+#include "node/color/colormanager/colormanager.h"
+
+namespace olive
+{
+
+const QString OCIOLutNode::kFileInput = QStringLiteral("lut_file_in");
+const QString OCIOLutNode::kDirectionInput = QStringLiteral("lut_dir_in");
+
+#define super OCIOBaseNode
+
+OCIOLutNode::OCIOLutNode()
+{
+ AddInput(kFileInput, NodeValue::kFile, QString(),
+ InputFlags(kInputFlagNotKeyframable | kInputFlagNotConnectable));
+ SetInputProperty(
+ kFileInput, QStringLiteral("filter"),
+ tr("LUT Files (*.cube *.3dl);;Cube LUT (*.cube);;3DL LUT (*.3dl);;All Files (*)"));
+ SetInputProperty(kFileInput, QStringLiteral("placeholder"),
+ tr("Select a .cube or .3dl LUT file"));
+
+ AddInput(kDirectionInput, NodeValue::kCombo, 0,
+ InputFlags(kInputFlagNotKeyframable | kInputFlagNotConnectable));
+}
+
+QString OCIOLutNode::Name() const
+{
+ return tr("OCIO LUT");
+}
+
+QString OCIOLutNode::id() const
+{
+ return QStringLiteral("org.olivevideoeditor.Olive.ociolut");
+}
+
+QVector OCIOLutNode::Category() const
+{
+ return { kCategoryColor };
+}
+
+QString OCIOLutNode::Description() const
+{
+ return tr("Applies a LUT file through OpenColorIO.");
+}
+
+void OCIOLutNode::Retranslate()
+{
+ super::Retranslate();
+
+ SetInputName(kTextureInput, tr("Input"));
+ SetInputName(kFileInput, tr("LUT File"));
+ SetInputName(kDirectionInput, tr("Direction"));
+ SetComboBoxStrings(kDirectionInput, { tr("Forward"), tr("Inverse") });
+}
+
+void OCIOLutNode::InputValueChangedEvent(const QString &input, int element)
+{
+ Q_UNUSED(element)
+
+ if (input == kFileInput || input == kDirectionInput) {
+ GenerateProcessor();
+ }
+}
+
+void OCIOLutNode::ConfigChanged()
+{
+ GenerateProcessor();
+}
+
+void OCIOLutNode::GenerateProcessor()
+{
+ if (!manager()) {
+ set_processor(nullptr);
+ return;
+ }
+
+ const QString path = GetStandardValue(kFileInput).toString();
+ if (path.isEmpty()) {
+ set_processor(nullptr);
+ return;
+ }
+
+ const QFileInfo info(path);
+ if (!info.exists() || !info.isFile()) {
+ qWarning() << "OCIO LUT file does not exist:" << path;
+ set_processor(nullptr);
+ return;
+ }
+
+ const QString suffix = info.suffix();
+ if (!OCIO::FileTransform::IsFormatExtensionSupported(suffix.toUtf8().constData())) {
+ qWarning() << "Unsupported OCIO LUT file extension:" << path;
+ set_processor(nullptr);
+ return;
+ }
+
+ try {
+ OCIO::FileTransformRcPtr transform = OCIO::FileTransform::Create();
+ transform->setSrc(path.toUtf8().constData());
+ transform->setInterpolation(OCIO::INTERP_LINEAR);
+ transform->setDirection(
+ static_cast(
+ GetStandardValue(kDirectionInput).toInt()) == ColorProcessor::kNormal
+ ? OCIO::TRANSFORM_DIR_FORWARD
+ : OCIO::TRANSFORM_DIR_INVERSE);
+
+ set_processor(ColorProcessor::Create(
+ manager()->GetConfig()->getProcessor(transform)));
+ } catch (const OCIO::Exception &e) {
+ qWarning() << "OCIO LUT processor error:" << e.what();
+ set_processor(nullptr);
+ }
+}
+
+} // namespace olive
diff --git a/app/node/color/ociolut/ociolut.h b/app/node/color/ociolut/ociolut.h
new file mode 100644
index 000000000..98daa8177
--- /dev/null
+++ b/app/node/color/ociolut/ociolut.h
@@ -0,0 +1,58 @@
+/***
+
+ 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 OCIOLUTNODE_H
+#define OCIOLUTNODE_H
+
+#include "node/color/ociobase/ociobase.h"
+#include "render/colorprocessor.h"
+
+namespace olive
+{
+
+class OCIOLutNode : public OCIOBaseNode {
+ Q_OBJECT
+public:
+ OCIOLutNode();
+
+ NODE_DEFAULT_FUNCTIONS(OCIOLutNode)
+
+ 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;
+
+ static const QString kFileInput;
+ static const QString kDirectionInput;
+
+protected slots:
+ virtual void ConfigChanged() override;
+
+private:
+ void GenerateProcessor();
+};
+
+} // namespace olive
+
+#endif // OCIOLUTNODE_H
diff --git a/app/node/color/threewaycolor/CMakeLists.txt b/app/node/color/threewaycolor/CMakeLists.txt
new file mode 100644
index 000000000..ee2ba14a2
--- /dev/null
+++ b/app/node/color/threewaycolor/CMakeLists.txt
@@ -0,0 +1,22 @@
+# Olive - Non-Linear Video Editor
+# Copyright (C) 2022 Olive 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/threewaycolor/threewaycolor.h
+ node/color/threewaycolor/threewaycolor.cpp
+ PARENT_SCOPE
+)
diff --git a/app/node/color/threewaycolor/threewaycolor.cpp b/app/node/color/threewaycolor/threewaycolor.cpp
new file mode 100644
index 000000000..3b03df09d
--- /dev/null
+++ b/app/node/color/threewaycolor/threewaycolor.cpp
@@ -0,0 +1,124 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2026 mikesolar
+
+ 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 "threewaycolor.h"
+
+#include
+
+#include "node/project.h"
+#include "widget/slider/floatslider.h"
+
+namespace olive
+{
+
+#define super Node
+
+const QString ThreeWayColorNode::kTextureInput = QStringLiteral("tex_in");
+const QString ThreeWayColorNode::kShadowsColorInput =
+ QStringLiteral("shadows_color_in");
+const QString ThreeWayColorNode::kMidtonesColorInput =
+ QStringLiteral("midtones_color_in");
+const QString ThreeWayColorNode::kHighlightsColorInput =
+ QStringLiteral("highlights_color_in");
+const QString ThreeWayColorNode::kShadowsAmountInput =
+ QStringLiteral("shadows_amount_in");
+const QString ThreeWayColorNode::kMidtonesAmountInput =
+ QStringLiteral("midtones_amount_in");
+const QString ThreeWayColorNode::kHighlightsAmountInput =
+ QStringLiteral("highlights_amount_in");
+const QString ThreeWayColorNode::kLumaCoefficientsInput =
+ QStringLiteral("luma_coefficients_in");
+
+ThreeWayColorNode::ThreeWayColorNode()
+{
+ AddInput(kTextureInput, NodeValue::kTexture,
+ InputFlags(kInputFlagNotKeyframable));
+
+ const QVariant neutral = QVariant::fromValue(Color(0.5, 0.5, 0.5, 1.0));
+ AddInput(kShadowsColorInput, NodeValue::kColor, neutral);
+ AddInput(kMidtonesColorInput, NodeValue::kColor, neutral);
+ AddInput(kHighlightsColorInput, NodeValue::kColor, neutral);
+
+ AddInput(kShadowsAmountInput, NodeValue::kFloat, 1.0);
+ AddInput(kMidtonesAmountInput, NodeValue::kFloat, 1.0);
+ AddInput(kHighlightsAmountInput, NodeValue::kFloat, 1.0);
+
+ const QString min = QStringLiteral("min");
+ const QString view = QStringLiteral("view");
+ SetInputProperty(kShadowsAmountInput, min, 0.0);
+ SetInputProperty(kMidtonesAmountInput, min, 0.0);
+ SetInputProperty(kHighlightsAmountInput, min, 0.0);
+ SetInputProperty(kShadowsAmountInput, view, FloatSlider::kPercentage);
+ SetInputProperty(kMidtonesAmountInput, view, FloatSlider::kPercentage);
+ SetInputProperty(kHighlightsAmountInput, view, FloatSlider::kPercentage);
+
+ SetEffectInput(kTextureInput);
+ SetFlag(kVideoEffect);
+}
+
+void ThreeWayColorNode::Retranslate()
+{
+ super::Retranslate();
+
+ SetInputName(kTextureInput, tr("Input"));
+ SetInputName(kShadowsColorInput, tr("Shadows"));
+ SetInputName(kMidtonesColorInput, tr("Midtones"));
+ SetInputName(kHighlightsColorInput, tr("Highlights"));
+ SetInputName(kShadowsAmountInput, tr("Shadows Amount"));
+ SetInputName(kMidtonesAmountInput, tr("Midtones Amount"));
+ SetInputName(kHighlightsAmountInput, tr("Highlights Amount"));
+}
+
+ShaderCode
+ThreeWayColorNode::GetShaderCode(const ShaderRequest &request) const
+{
+ Q_UNUSED(request)
+ return ShaderCode(
+ FileFunctions::ReadFileAsString(":/shaders/threewaycolor.frag"));
+}
+
+void ThreeWayColorNode::Value(const NodeValueRow &value,
+ const NodeGlobals &globals,
+ NodeValueTable *table) const
+{
+ Q_UNUSED(globals)
+
+ if (TexturePtr tex = value[kTextureInput].toTexture()) {
+ ShaderJob job(value);
+
+ double luma_coeffs[3] = { 0.0, 0.0, 0.0 };
+ if (project() && project()->color_manager()) {
+ project()->color_manager()->GetDefaultLumaCoefs(luma_coeffs);
+ } else {
+ luma_coeffs[0] = 0.2126;
+ luma_coeffs[1] = 0.7152;
+ luma_coeffs[2] = 0.0722;
+ }
+ job.Insert(kLumaCoefficientsInput,
+ NodeValue(NodeValue::kVec3,
+ QVector3D(luma_coeffs[0], luma_coeffs[1],
+ luma_coeffs[2])));
+
+ table->Push(NodeValue::kTexture, tex->toJob(job), this);
+ }
+}
+
+}
diff --git a/app/node/color/threewaycolor/threewaycolor.h b/app/node/color/threewaycolor/threewaycolor.h
new file mode 100644
index 000000000..33b34ab78
--- /dev/null
+++ b/app/node/color/threewaycolor/threewaycolor.h
@@ -0,0 +1,77 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2026 mikesolar
+
+ 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 THREEWAYCOLORNODE_H
+#define THREEWAYCOLORNODE_H
+
+#include "node/node.h"
+
+namespace olive
+{
+
+class ThreeWayColorNode : public Node {
+ Q_OBJECT
+public:
+ ThreeWayColorNode();
+
+ NODE_DEFAULT_FUNCTIONS(ThreeWayColorNode)
+
+ virtual QString Name() const override
+ {
+ return tr("Three-Way Color");
+ }
+
+ virtual QString id() const override
+ {
+ return QStringLiteral("org.olivevideoeditor.Olive.threewaycolor");
+ }
+
+ virtual QVector Category() const override
+ {
+ return { kCategoryColor };
+ }
+
+ virtual QString Description() const override
+ {
+ return tr("Adjusts shadows, midtones, and highlights separately.");
+ }
+
+ virtual void Retranslate() override;
+
+ virtual ShaderCode
+ GetShaderCode(const ShaderRequest &request) const override;
+
+ virtual void Value(const NodeValueRow &value, const NodeGlobals &globals,
+ NodeValueTable *table) const override;
+
+ static const QString kTextureInput;
+ static const QString kShadowsColorInput;
+ static const QString kMidtonesColorInput;
+ static const QString kHighlightsColorInput;
+ static const QString kShadowsAmountInput;
+ static const QString kMidtonesAmountInput;
+ static const QString kHighlightsAmountInput;
+ static const QString kLumaCoefficientsInput;
+};
+
+}
+
+#endif // THREEWAYCOLORNODE_H
diff --git a/app/node/factory.cpp b/app/node/factory.cpp
index aea671386..cef0be9ab 100644
--- a/app/node/factory.cpp
+++ b/app/node/factory.cpp
@@ -32,6 +32,8 @@
#include "block/transition/diptocolor/diptocolortransition.h"
#include "color/displaytransform/displaytransform.h"
#include "color/ociogradingtransformlinear/ociogradingtransformlinear.h"
+#include "color/ociolut/ociolut.h"
+#include "color/threewaycolor/threewaycolor.h"
#include "common/Current.h"
#include "distort/cornerpin/cornerpindistortnode.h"
#include "distort/crop/cropdistortnode.h"
@@ -358,6 +360,10 @@ Node *NodeFactory::CreateFromFactoryIndex(const NodeFactory::InternalID &id)
return new DisplayTransformNode();
case kOCIOGradingTransformLinear:
return new OCIOGradingTransformLinearNode();
+ case kOCIOLut:
+ return new OCIOLutNode();
+ case kThreeWayColor:
+ return new ThreeWayColorNode();
case kChromaKey:
return new ChromaKeyNode();
case kMaskDistort:
diff --git a/app/node/factory.h b/app/node/factory.h
index 764386a5c..5651c02a9 100644
--- a/app/node/factory.h
+++ b/app/node/factory.h
@@ -73,6 +73,8 @@ public:
kCornerPinDistort,
kDisplayTransform,
kOCIOGradingTransformLinear,
+ kOCIOLut,
+ kThreeWayColor,
kChromaKey,
kMaskDistort,
kDropShadowFilter,
diff --git a/app/panel/scope/scope.cpp b/app/panel/scope/scope.cpp
index a45e1f8f0..d6aedbef0 100644
--- a/app/panel/scope/scope.cpp
+++ b/app/panel/scope/scope.cpp
@@ -59,6 +59,10 @@ ScopePanel::ScopePanel()
waveform_view_ = new WaveformScope();
stack_->addWidget(waveform_view_);
+ // Create vectorscope
+ vectorscope_ = new VectorscopeScope();
+ stack_->addWidget(vectorscope_);
+
// Create histogram
histogram_ = new HistogramScope();
stack_->addWidget(histogram_);
@@ -81,6 +85,8 @@ QString ScopePanel::TypeToName(ScopePanel::Type t)
switch (t) {
case kTypeWaveform:
return tr("Waveform");
+ case kTypeVectorscope:
+ return tr("Vectorscope");
case kTypeHistogram:
return tr("Histogram");
case kTypeCount:
@@ -124,12 +130,14 @@ void ScopePanel::SetViewerPanel(ViewerPanelBase *vp)
void ScopePanel::SetReferenceBuffer(TexturePtr frame)
{
histogram_->SetBuffer(frame);
+ vectorscope_->SetBuffer(frame);
waveform_view_->SetBuffer(frame);
}
void ScopePanel::SetColorManager(ColorManager *manager)
{
histogram_->ConnectColorManager(manager);
+ vectorscope_->ConnectColorManager(manager);
waveform_view_->ConnectColorManager(manager);
}
diff --git a/app/panel/scope/scope.h b/app/panel/scope/scope.h
index edcd07d2a..3286bab7c 100644
--- a/app/panel/scope/scope.h
+++ b/app/panel/scope/scope.h
@@ -28,6 +28,7 @@
#include "panel/panel.h"
#include "panel/viewer/viewerbase.h"
#include "widget/scope/histogram/histogram.h"
+#include "widget/scope/vectorscope/vectorscope.h"
#include "widget/scope/waveform/waveform.h"
namespace olive
@@ -38,6 +39,7 @@ class ScopePanel : public PanelWidget {
public:
enum Type {
kTypeWaveform,
+ kTypeVectorscope,
kTypeHistogram,
kTypeCount
@@ -73,6 +75,8 @@ private:
WaveformScope *waveform_view_;
+ VectorscopeScope *vectorscope_;
+
HistogramScope *histogram_;
ViewerPanelBase *viewer_;
diff --git a/app/shaders/rgbvectorscope.frag b/app/shaders/rgbvectorscope.frag
new file mode 100644
index 000000000..7af6a48ad
--- /dev/null
+++ b/app/shaders/rgbvectorscope.frag
@@ -0,0 +1,48 @@
+uniform sampler2D ove_maintex;
+
+uniform vec2 viewport;
+uniform vec3 luma_coeffs;
+
+uniform float vectorscope_gain;
+uniform float vectorscope_point_radius;
+uniform float vectorscope_intensity;
+uniform float vectorscope_sample_grid;
+
+in vec2 ove_texcoord;
+out vec4 frag_color;
+
+vec2 rgb_to_vectorscope(vec3 rgb)
+{
+ float y = dot(rgb, luma_coeffs);
+ float cb = (rgb.b - y) / max(2.0 * (1.0 - luma_coeffs.b), 0.0001);
+ float cr = (rgb.r - y) / max(2.0 * (1.0 - luma_coeffs.r), 0.0001);
+ return vec2(cr, cb) * vectorscope_gain + vec2(0.5);
+}
+
+void main(void)
+{
+ float grid = max(vectorscope_sample_grid, 1.0);
+ float point_radius = vectorscope_point_radius / max(min(viewport.x, viewport.y), 1.0);
+ vec3 accumulated = vec3(0.0);
+
+ for (int y = 0; y < 64; y++) {
+ if (float(y) >= grid) {
+ break;
+ }
+
+ for (int x = 0; x < 64; x++) {
+ if (float(x) >= grid) {
+ break;
+ }
+
+ vec2 sample_uv = (vec2(float(x), float(y)) + vec2(0.5)) / grid;
+ vec3 sample_rgb = clamp(texture(ove_maintex, sample_uv).rgb, 0.0, 1.0);
+ vec2 scope_point = rgb_to_vectorscope(sample_rgb);
+ float distance_to_point = distance(ove_texcoord, scope_point);
+ float contribution = 1.0 - smoothstep(point_radius, point_radius * 2.0, distance_to_point);
+ accumulated += sample_rgb * contribution * vectorscope_intensity;
+ }
+ }
+
+ frag_color = vec4(clamp(accumulated, 0.0, 1.0), 1.0);
+}
diff --git a/app/shaders/rgbvectorscope.vert b/app/shaders/rgbvectorscope.vert
new file mode 100644
index 000000000..766fba13a
--- /dev/null
+++ b/app/shaders/rgbvectorscope.vert
@@ -0,0 +1,25 @@
+uniform float vectorscope_scale;
+
+in vec4 a_position;
+in vec2 a_texcoord;
+
+out vec2 ove_texcoord;
+
+mat4 scale_mat4(vec3 scale)
+{
+ return mat4(
+ scale.x, 0.0, 0.0, 0.0,
+ 0.0, scale.y, 0.0, 0.0,
+ 0.0, 0.0, scale.z, 0.0,
+ 0.0, 0.0, 0.0, 1.0
+ );
+}
+
+void main()
+{
+ mat4 transform = mat4(1.0);
+ transform *= scale_mat4(vec3(vectorscope_scale, vectorscope_scale, 1.0));
+
+ gl_Position = transform * a_position;
+ ove_texcoord = a_texcoord;
+}
diff --git a/app/shaders/threewaycolor.frag b/app/shaders/threewaycolor.frag
new file mode 100644
index 000000000..5b100e59f
--- /dev/null
+++ b/app/shaders/threewaycolor.frag
@@ -0,0 +1,35 @@
+uniform sampler2D tex_in;
+
+uniform vec4 shadows_color_in;
+uniform vec4 midtones_color_in;
+uniform vec4 highlights_color_in;
+uniform float shadows_amount_in;
+uniform float midtones_amount_in;
+uniform float highlights_amount_in;
+uniform vec3 luma_coefficients_in;
+
+in vec2 ove_texcoord;
+out vec4 frag_color;
+
+vec3 color_offset(vec4 control, float amount)
+{
+ return (control.rgb - vec3(0.5)) * 2.0 * amount;
+}
+
+void main(void)
+{
+ vec4 source = texture(tex_in, ove_texcoord);
+ float luma = clamp(dot(source.rgb, luma_coefficients_in), 0.0, 1.0);
+
+ float shadow_weight = smoothstep(0.75, 0.0, luma);
+ float highlight_weight = smoothstep(0.25, 1.0, luma);
+ float midtone_weight = clamp(1.0 - abs(luma - 0.5) * 2.0, 0.0, 1.0);
+
+ vec3 adjustment =
+ color_offset(shadows_color_in, shadows_amount_in) * shadow_weight +
+ color_offset(midtones_color_in, midtones_amount_in) * midtone_weight +
+ color_offset(highlights_color_in, highlights_amount_in) * highlight_weight;
+
+ vec3 graded = source.rgb + adjustment * source.rgb * (1.0 - source.rgb);
+ frag_color = vec4(clamp(graded, 0.0, 1.0), source.a);
+}
diff --git a/app/widget/filefield/filefield.cpp b/app/widget/filefield/filefield.cpp
index 70e2ae697..1f02099c0 100644
--- a/app/widget/filefield/filefield.cpp
+++ b/app/widget/filefield/filefield.cpp
@@ -59,7 +59,8 @@ void FileField::BrowseBtnClicked()
if (directory_mode_) {
s = QFileDialog::getExistingDirectory(this, tr("Open Directory"));
} else {
- s = QFileDialog::getOpenFileName(this, tr("Open File"));
+ s = QFileDialog::getOpenFileName(this, tr("Open File"), QString(),
+ name_filter_);
}
if (!s.isEmpty()) {
diff --git a/app/widget/filefield/filefield.h b/app/widget/filefield/filefield.h
index 7bb4915b4..472f8944f 100644
--- a/app/widget/filefield/filefield.h
+++ b/app/widget/filefield/filefield.h
@@ -53,6 +53,11 @@ public:
directory_mode_ = e;
}
+ void SetNameFilter(const QString &filter)
+ {
+ name_filter_ = filter;
+ }
+
signals:
void FilenameChanged(const QString &filename);
@@ -63,6 +68,8 @@ private:
bool directory_mode_;
+ QString name_filter_;
+
private slots:
void BrowseBtnClicked();
diff --git a/app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp b/app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp
index 5a384db55..1ce418054 100644
--- a/app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp
+++ b/app/widget/nodeparamview/nodeparamviewwidgetbridge.cpp
@@ -949,6 +949,8 @@ void NodeParamViewWidgetBridge::SetProperty(const QString &key,
ff->SetPlaceholder(value.toString());
} else if (key == QStringLiteral("directory")) {
ff->SetDirectoryMode(value.toBool());
+ } else if (key == QStringLiteral("filter")) {
+ ff->SetNameFilter(value.toString());
}
}
diff --git a/app/widget/scope/CMakeLists.txt b/app/widget/scope/CMakeLists.txt
index 4eb246120..fd40b03a1 100644
--- a/app/widget/scope/CMakeLists.txt
+++ b/app/widget/scope/CMakeLists.txt
@@ -16,6 +16,7 @@
add_subdirectory(histogram)
add_subdirectory(scopebase)
+add_subdirectory(vectorscope)
add_subdirectory(waveform)
set(OLIVE_SOURCES
diff --git a/app/widget/scope/vectorscope/CMakeLists.txt b/app/widget/scope/vectorscope/CMakeLists.txt
new file mode 100644
index 000000000..d6ee4f89e
--- /dev/null
+++ b/app/widget/scope/vectorscope/CMakeLists.txt
@@ -0,0 +1,22 @@
+# Olive - Non-Linear Video Editor
+# Copyright (C) 2022 Olive 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}
+ widget/scope/vectorscope/vectorscope.h
+ widget/scope/vectorscope/vectorscope.cpp
+ PARENT_SCOPE
+)
diff --git a/app/widget/scope/vectorscope/vectorscope.cpp b/app/widget/scope/vectorscope/vectorscope.cpp
new file mode 100644
index 000000000..4680aee14
--- /dev/null
+++ b/app/widget/scope/vectorscope/vectorscope.cpp
@@ -0,0 +1,132 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2026 mikesolar
+
+ 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 "vectorscope.h"
+
+#include
+#include
+#include
+#include
+
+#include "common/qtutils.h"
+#include "node/node.h"
+
+namespace olive
+{
+
+#define super ScopeBase
+
+VectorscopeScope::VectorscopeScope(QWidget *parent)
+ : super(parent)
+{
+}
+
+ShaderCode VectorscopeScope::GenerateShaderCode()
+{
+ return ShaderCode(
+ FileFunctions::ReadFileAsString(":/shaders/rgbvectorscope.frag"),
+ FileFunctions::ReadFileAsString(":/shaders/rgbvectorscope.vert"));
+}
+
+void VectorscopeScope::DrawScope(TexturePtr managed_tex, QVariant pipeline)
+{
+ float vectorscope_scale = 0.80f;
+ float vectorscope_gain = 1.45f;
+ float vectorscope_point_radius = 1.75f;
+ float vectorscope_intensity = 0.035f;
+ float vectorscope_sample_grid = 28.0f;
+
+ ShaderJob job;
+
+ job.Insert(QStringLiteral("viewport"),
+ NodeValue(NodeValue::kVec2, QVector2D(width(), height())));
+
+ double luma_coeffs[3] = { 0.0f, 0.0f, 0.0f };
+ color_manager()->GetDefaultLumaCoefs(luma_coeffs);
+ job.Insert(
+ QStringLiteral("luma_coeffs"),
+ NodeValue(NodeValue::kVec3,
+ QVector3D(luma_coeffs[0], luma_coeffs[1], luma_coeffs[2])));
+
+ job.Insert(QStringLiteral("vectorscope_scale"),
+ NodeValue(NodeValue::kFloat, vectorscope_scale));
+ job.Insert(QStringLiteral("vectorscope_gain"),
+ NodeValue(NodeValue::kFloat, vectorscope_gain));
+ job.Insert(QStringLiteral("vectorscope_point_radius"),
+ NodeValue(NodeValue::kFloat, vectorscope_point_radius));
+ job.Insert(QStringLiteral("vectorscope_intensity"),
+ NodeValue(NodeValue::kFloat, vectorscope_intensity));
+ job.Insert(QStringLiteral("vectorscope_sample_grid"),
+ NodeValue(NodeValue::kFloat, vectorscope_sample_grid));
+
+ job.Insert(QStringLiteral("ove_maintex"),
+ NodeValue(NodeValue::kTexture,
+ QVariant::fromValue(managed_tex)));
+
+ renderer()->Blit(pipeline, job, GetViewportParams());
+
+ QPainter p(paint_device());
+ QFont font = p.font();
+ font.setPixelSize(10);
+ QFontMetrics font_metrics = QFontMetrics(font);
+
+ p.setCompositionMode(QPainter::CompositionMode_Plus);
+ p.setPen(QColor(0, 153, 0));
+ p.setFont(font);
+
+ float scope_size = qMin(width(), height()) * vectorscope_scale;
+ QPointF center(width() * 0.5, height() * 0.5);
+ float radius = scope_size * 0.5;
+
+ p.drawEllipse(center, radius, radius);
+ p.drawLine(QPointF(center.x() - radius, center.y()),
+ QPointF(center.x() + radius, center.y()));
+ p.drawLine(QPointF(center.x(), center.y() - radius),
+ QPointF(center.x(), center.y() + radius));
+
+ struct Target {
+ const char *label;
+ float angle;
+ };
+ const Target targets[] = {
+ { "R", 0.0f }, { "Mg", 60.0f }, { "B", 120.0f },
+ { "Cy", 180.0f }, { "G", 240.0f }, { "Yl", 300.0f },
+ };
+
+ const float label_radius = radius + 12.0f;
+ const float marker_radius = radius * 0.72f;
+ constexpr float kPi = 3.14159265358979323846f;
+
+ for (const Target &target : targets) {
+ float radians = target.angle * kPi / 180.0f;
+ QPointF direction(qCos(radians), -qSin(radians));
+ QPointF marker = center + direction * marker_radius;
+ QPointF label_pos = center + direction * label_radius;
+ QString label = QString::fromUtf8(target.label);
+
+ p.drawEllipse(marker, 3.0, 3.0);
+ p.drawText(label_pos.x() -
+ QtUtils::QFontMetricsWidth(font_metrics, label) * 0.5,
+ label_pos.y() + font_metrics.capHeight() * 0.5, label);
+ }
+}
+
+}
diff --git a/app/widget/scope/vectorscope/vectorscope.h b/app/widget/scope/vectorscope/vectorscope.h
new file mode 100644
index 000000000..2dbbe3eb3
--- /dev/null
+++ b/app/widget/scope/vectorscope/vectorscope.h
@@ -0,0 +1,45 @@
+/***
+
+ Olive - Non-Linear Video Editor
+ Copyright (C) 2022 Olive Team
+ Modifications Copyright (C) 2026 mikesolar
+
+ 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 VECTORSCOPESCOPE_H
+#define VECTORSCOPESCOPE_H
+
+#include "widget/scope/scopebase/scopebase.h"
+
+namespace olive
+{
+
+class VectorscopeScope : public ScopeBase {
+ Q_OBJECT
+public:
+ VectorscopeScope(QWidget *parent = nullptr);
+
+ MANAGEDDISPLAYWIDGET_DEFAULT_DESTRUCTOR(VectorscopeScope)
+
+protected:
+ virtual ShaderCode GenerateShaderCode() override;
+
+ virtual void DrawScope(TexturePtr managed_tex, QVariant pipeline) override;
+};
+
+}
+
+#endif // VECTORSCOPESCOPE_H
diff --git a/docs/zh/color-lut-v04-plan.md b/docs/zh/color-lut-v04-plan.md
new file mode 100644
index 000000000..1fe16cb2d
--- /dev/null
+++ b/docs/zh/color-lut-v04-plan.md
@@ -0,0 +1,114 @@
+# v0.4 调色与 LUT 实施计划
+
+本文档对应 `docs/zh/README.md` 路线图中 v0.4「调色与 LUT」里程碑。
+
+## 目标
+
+- 支持 `.cube` 与 `.3dl` LUT 文件作为可用调色入口。
+- 完成示波器面板的波形、矢量、直方图三类视图。
+- 提供三向色轮面板,面向阴影、中间调、高光做基础调色控制。
+- 尽量复用现有 OpenColorIO、节点系统、Viewer/Scope 面板和 GPU 渲染管线,不引入独立的调色框架。
+
+## 当前状态
+
+- 已有 OpenColorIO 基础能力:颜色管理、显示变换、OCIO 调色节点和渲染侧配置。
+- Scope 面板已提供波形、矢量、直方图三类视图。
+- 已有色轮基础控件;当前三向调色先通过节点参数面板暴露 Shadows、Midtones、Highlights 三组颜色与强度参数。
+- LUT 节点已接入节点工厂,并已增加 `.cube`/`.3dl` 相关测试。
+
+## 阶段 1:LUT 节点入口
+
+状态:已完成首版。
+
+交付内容:
+
+- 新增 OCIO LUT 节点,使用 OpenColorIO `FileTransform` 读取外部 LUT 文件。
+- 明确支持 `.cube` 与 `.3dl` 扩展名,并拒绝未知格式。
+- 在节点工厂中注册 LUT 节点,保证工程加载和节点创建路径一致。
+- 增加 gtest 覆盖 LUT 扩展名支持和简单 LUT 转换结果。
+
+验收标准:
+
+- `olive-gtest` 中 LUT 相关测试通过。
+- `olive-editor` 和 `olive-render-worker` 可正常构建。
+- LUT 文件缺失、格式不支持、OCIO 处理器创建失败时不会导致崩溃。
+
+## 阶段 2:示波器补齐
+
+状态:已完成首版。
+
+交付内容:
+
+- 保留现有波形和直方图视图。
+- 新增矢量示波器视图,并接入 Scope 面板下拉选择。
+- 矢量示波器应使用当前 Viewer 帧,并经过现有显示/颜色管理路径。
+- 为新增 shader 或资源入口增加资源存在性测试。
+
+验收标准:
+
+- Scope 面板可在 Waveform、Vectorscope、Histogram 间切换。
+- 无当前帧时视图保持空白或安全占位,不崩溃。
+- shader 资源测试和编辑器构建通过。
+
+## 阶段 3:三向色轮面板
+
+状态:已完成节点参数面板首版;独立三向色轮 dock 面板作为后续体验增强。
+
+交付内容:
+
+- 基于现有参数面板提供 Shadows、Midtones、Highlights 三组控制。
+- 为每组控制提供色彩偏移和强度/亮度相关参数。
+- 将三向色轮参数映射到现有 OCIO 调色节点,或新增可序列化节点承载参数。
+- 保证参数能随工程保存、加载、撤销和重做。
+
+验收标准:
+
+- 用户可以在 UI 中操作三向调色参数并看到 Viewer 结果变化。
+- 参数在工程文件中可序列化并可恢复。
+- 节点参数变更不破坏现有 OCIO 调色节点兼容性。
+
+## 阶段 4:集成与体验
+
+状态:当前范围已完成;独立三向色轮 dock 面板和更细的交互体验作为后续增强。
+
+交付内容:
+
+- 为 LUT 节点补齐清晰的文件选择过滤器和用户可见名称。已完成。
+- 在调色相关 UI 中保持命名一致:LUT、Waveform、Vectorscope、Histogram、Shadows、Midtones、Highlights。已完成首版。
+- 更新中文文档,说明 LUT、示波器和三向调色的当前入口。已在本文档记录。
+
+验收标准:
+
+- 用户能从现有节点/UI 路径发现 LUT 和调色功能。
+- 文档与实际 UI 命名一致。
+- LUT 文件选择器限制为 `.cube` 与 `.3dl`,并保留 All Files 兜底。
+- 不引入和现有翻译系统冲突的硬编码字符串。
+
+## 阶段 5:验证
+
+状态:自动化构建和核心测试已通过;手动 Viewer/Scope 观感检查将在真实项目中继续验证。
+
+构建命令:
+
+```sh
+ninja -C cmake-build-debug olive-gtest olive-editor olive-render-worker -j2
+```
+
+测试命令:
+
+```sh
+QT_QPA_PLATFORM=offscreen cmake-build-debug/tests/gtest/olive-gtest --gtest_filter='ColorLut.*:ColorV04.*:Shaders.*:NodeSerialization.*:NodeValue.*' --gtest_brief=1
+```
+
+手动检查:
+
+- 打开工程并加载一段素材。
+- 在 Scope 面板分别切换 Waveform、Vectorscope、Histogram。
+- 添加 LUT 节点并选择 `.cube` 或 `.3dl` 文件。
+- 调整三向色轮参数,确认 Viewer 输出和工程保存/加载行为。
+
+## 风险与待定点
+
+- 三向色轮应优先映射到 OCIO 现有调色能力;如果现有节点表达能力不足,再新增独立节点。
+- 矢量示波器 shader 需要兼容当前 OpenGL 版本和已有渲染抽象,避免只在单一驱动上可用。
+- LUT 文件路径序列化需要尊重现有工程文件路径策略,避免绝对路径导致工程不可迁移。
diff --git a/tests/gtest/CMakeLists.txt b/tests/gtest/CMakeLists.txt
index 864795c56..87b338477 100644
--- a/tests/gtest/CMakeLists.txt
+++ b/tests/gtest/CMakeLists.txt
@@ -3,6 +3,7 @@ add_executable(olive-gtest
common_current_test.cpp
common_xmlutils_test.cpp
config_test.cpp
+ color_lut_test.cpp
node_value_test.cpp
node_keyframe_test.cpp
node_serialization_test.cpp
diff --git a/tests/gtest/color_lut_test.cpp b/tests/gtest/color_lut_test.cpp
new file mode 100644
index 000000000..5b58f369e
--- /dev/null
+++ b/tests/gtest/color_lut_test.cpp
@@ -0,0 +1,103 @@
+#include
+
+#include
+#include
+#include
+
+#include
+
+#include
+
+#include "node/color/ociolut/ociolut.h"
+#include "node/color/threewaycolor/threewaycolor.h"
+#include "node/factory.h"
+#include "render/colorprocessor.h"
+
+namespace OCIO = OCIO_NAMESPACE;
+
+namespace {
+
+QString WriteTestCube(QTemporaryDir *dir)
+{
+ const QString path = QDir(dir->path()).filePath(QStringLiteral("invert.cube"));
+ QFile file(path);
+ if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
+ return QString();
+ }
+
+ const QByteArray data =
+ "TITLE \"Oak test invert\"\n"
+ "LUT_1D_SIZE 2\n"
+ "DOMAIN_MIN 0.0 0.0 0.0\n"
+ "DOMAIN_MAX 1.0 1.0 1.0\n"
+ "1.0 1.0 1.0\n"
+ "0.0 0.0 0.0\n";
+ file.write(data);
+ file.close();
+ return path;
+}
+
+} // namespace
+
+TEST(ColorLut, OcioSupportsCubeAnd3dlExtensions)
+{
+ EXPECT_TRUE(OCIO::FileTransform::IsFormatExtensionSupported("cube"));
+ EXPECT_TRUE(OCIO::FileTransform::IsFormatExtensionSupported(".cube"));
+ EXPECT_TRUE(OCIO::FileTransform::IsFormatExtensionSupported("3dl"));
+ EXPECT_TRUE(OCIO::FileTransform::IsFormatExtensionSupported(".3dl"));
+}
+
+TEST(ColorLut, CubeFileTransformConvertsColor)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ const QString path = WriteTestCube(&dir);
+ ASSERT_FALSE(path.isEmpty());
+
+ 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->ConvertColor(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(ColorV04, FactoryCreatesColorNodes)
+{
+ std::unique_ptr lut(
+ olive::NodeFactory::CreateFromFactoryIndex(
+ olive::NodeFactory::kOCIOLut));
+ ASSERT_NE(lut, nullptr);
+ EXPECT_EQ(lut->id(), QStringLiteral("org.olivevideoeditor.Olive.ociolut"));
+
+ std::unique_ptr three_way(
+ olive::NodeFactory::CreateFromFactoryIndex(
+ olive::NodeFactory::kThreeWayColor));
+ ASSERT_NE(three_way, nullptr);
+ EXPECT_EQ(three_way->id(),
+ QStringLiteral("org.olivevideoeditor.Olive.threewaycolor"));
+ EXPECT_TRUE(three_way->HasInputWithID(
+ olive::ThreeWayColorNode::kShadowsColorInput));
+ EXPECT_TRUE(three_way->HasInputWithID(
+ olive::ThreeWayColorNode::kMidtonesColorInput));
+ EXPECT_TRUE(three_way->HasInputWithID(
+ olive::ThreeWayColorNode::kHighlightsColorInput));
+
+ const olive::Color neutral =
+ three_way->GetStandardValue(
+ olive::ThreeWayColorNode::kMidtonesColorInput)
+ .value();
+ EXPECT_FLOAT_EQ(neutral.red(), 0.5f);
+ EXPECT_FLOAT_EQ(neutral.green(), 0.5f);
+ EXPECT_FLOAT_EQ(neutral.blue(), 0.5f);
+ EXPECT_FLOAT_EQ(neutral.alpha(), 1.0f);
+}
diff --git a/tests/gtest/shader_resources_test.cpp b/tests/gtest/shader_resources_test.cpp
index 8997a0d75..f11fe6a7f 100644
--- a/tests/gtest/shader_resources_test.cpp
+++ b/tests/gtest/shader_resources_test.cpp
@@ -10,7 +10,10 @@ TEST(Shaders, ResourcesAvailable)
QStringLiteral(":/shaders/yuv2rgb.frag"),
QStringLiteral(":/shaders/deinterlace2.frag"),
QStringLiteral(":/shaders/rgbhistogram.frag"),
- QStringLiteral(":/shaders/rgbhistogram.vert")
+ QStringLiteral(":/shaders/rgbhistogram.vert"),
+ QStringLiteral(":/shaders/rgbvectorscope.frag"),
+ QStringLiteral(":/shaders/rgbvectorscope.vert"),
+ QStringLiteral(":/shaders/threewaycolor.frag")
};
for (const QString &path : shader_paths) {