Huge refactor of color grade effect, including keyframable curves for R,G,B, and All using a new AnmiatedCurve class which supports a variable # of nodes for adjusting colors. Also, keyframable color wheels, amount sliders, and luma sliders. So now our color grade effect is fully controllable and keyframable. Including new unit tests for color grade effect.

This commit is contained in:
Jonathan Thomas
2026-04-22 15:59:50 -05:00
parent 0c1e0cd5ae
commit 291d3a5bf1
8 changed files with 610 additions and 278 deletions
+179
View File
@@ -0,0 +1,179 @@
/**
* @file
* @brief Source file for AnimatedCurve classes
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "AnimatedCurve.h"
#include "Exceptions.h"
#include <algorithm>
#include <cmath>
#include <sstream>
using namespace openshot;
namespace {
constexpr double kCurveDomainMax = 255.0;
static double clamp01(const double value) {
return std::max(0.0, std::min(1.0, value));
}
}
AnimatedCurveNode::AnimatedCurveNode()
: AnimatedCurveNode(0, 0.0, 0.0, LINEAR) {}
AnimatedCurveNode::AnimatedCurveNode(int node_id, double node_x, double node_y, InterpolationType node_interpolation)
: id(node_id),
x(clamp01(node_x)),
y(clamp01(node_y)),
left_handle_x(0.5),
left_handle_y(1.0),
right_handle_x(0.5),
right_handle_y(0.0),
interpolation(node_interpolation),
handle_type(AUTO) {}
Point AnimatedCurveNode::Evaluate(int64_t frame_number, double x_scale) const {
Point point(static_cast<float>(clamp01(x.GetValue(frame_number)) * x_scale),
static_cast<float>(clamp01(y.GetValue(frame_number))),
interpolation);
point.handle_left = Coordinate(clamp01(left_handle_x.GetValue(frame_number)),
clamp01(left_handle_y.GetValue(frame_number)));
point.handle_right = Coordinate(clamp01(right_handle_x.GetValue(frame_number)),
clamp01(right_handle_y.GetValue(frame_number)));
point.handle_type = handle_type;
return point;
}
std::string AnimatedCurveNode::Json() const {
return JsonValue().toStyledString();
}
Json::Value AnimatedCurveNode::JsonValue() const {
Json::Value root(Json::objectValue);
root["id"] = id;
root["x"] = x.JsonValue();
root["y"] = y.JsonValue();
root["left_handle_x"] = left_handle_x.JsonValue();
root["left_handle_y"] = left_handle_y.JsonValue();
root["right_handle_x"] = right_handle_x.JsonValue();
root["right_handle_y"] = right_handle_y.JsonValue();
root["interpolation"] = interpolation;
root["handle_type"] = handle_type;
return root;
}
void AnimatedCurveNode::SetJson(const std::string value) {
try {
SetJsonValue(openshot::stringToJson(value));
} catch (const std::exception&) {
throw InvalidJSON("JSON is invalid (missing keys or invalid data types)");
}
}
void AnimatedCurveNode::SetJsonValue(const Json::Value& root) {
if (!root["id"].isNull())
id = root["id"].asInt();
if (!root["x"].isNull())
x.SetJsonValue(root["x"]);
if (!root["y"].isNull())
y.SetJsonValue(root["y"]);
if (!root["left_handle_x"].isNull())
left_handle_x.SetJsonValue(root["left_handle_x"]);
if (!root["left_handle_y"].isNull())
left_handle_y.SetJsonValue(root["left_handle_y"]);
if (!root["right_handle_x"].isNull())
right_handle_x.SetJsonValue(root["right_handle_x"]);
if (!root["right_handle_y"].isNull())
right_handle_y.SetJsonValue(root["right_handle_y"]);
if (!root["interpolation"].isNull())
interpolation = static_cast<InterpolationType>(root["interpolation"].asInt());
if (!root["handle_type"].isNull())
handle_type = static_cast<HandleType>(root["handle_type"].asInt());
}
AnimatedCurve::AnimatedCurve()
: enabled(1.0) {
nodes.emplace_back(0, 0.0, 0.0, LINEAR);
nodes.emplace_back(1, 1.0, 1.0, LINEAR);
}
Keyframe AnimatedCurve::BuildCurve(int64_t frame_number, double x_scale) const {
Keyframe curve;
std::vector<AnimatedCurveNode> ordered_nodes = nodes;
std::sort(ordered_nodes.begin(), ordered_nodes.end(),
[frame_number](const AnimatedCurveNode& lhs, const AnimatedCurveNode& rhs) {
const double lhs_x = lhs.x.GetValue(frame_number);
const double rhs_x = rhs.x.GetValue(frame_number);
if (lhs_x == rhs_x)
return lhs.id < rhs.id;
return lhs_x < rhs_x;
});
for (const auto& node : ordered_nodes) {
curve.AddPoint(node.Evaluate(frame_number, x_scale));
}
return curve;
}
float AnimatedCurve::Sample(float input, int64_t frame_number) const {
if (enabled.GetValue(frame_number) < 0.5)
return static_cast<float>(clamp01(input));
const Keyframe curve = BuildCurve(frame_number, kCurveDomainMax);
return static_cast<float>(clamp01(curve.GetValue(std::lround(clamp01(input) * kCurveDomainMax))));
}
std::string AnimatedCurve::Summary(int64_t frame_number) const {
std::ostringstream ss;
if (enabled.GetValue(frame_number) < 0.5)
ss << "Disabled, ";
ss << nodes.size() << " nodes";
return ss.str();
}
std::string AnimatedCurve::Json() const {
return JsonValue().toStyledString();
}
Json::Value AnimatedCurve::JsonValue() const {
Json::Value root(Json::objectValue);
root["enabled"] = enabled.JsonValue();
root["nodes"] = Json::Value(Json::arrayValue);
for (const auto& node : nodes)
root["nodes"].append(node.JsonValue());
return root;
}
void AnimatedCurve::SetJson(const std::string value) {
try {
SetJsonValue(openshot::stringToJson(value));
} catch (const std::exception&) {
throw InvalidJSON("JSON is invalid (missing keys or invalid data types)");
}
}
void AnimatedCurve::SetJsonValue(const Json::Value& root) {
if (!root["enabled"].isNull())
enabled.SetJsonValue(root["enabled"]);
if (root["nodes"].isArray()) {
std::vector<AnimatedCurveNode> parsed_nodes;
for (const auto& item : root["nodes"]) {
AnimatedCurveNode node;
node.SetJsonValue(item);
parsed_nodes.push_back(node);
}
if (!parsed_nodes.empty())
nodes.swap(parsed_nodes);
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* @file
* @brief Header file for AnimatedCurve classes
* @author Jonathan Thomas <jonathan@openshot.org>
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#ifndef OPENSHOT_ANIMATED_CURVE_H
#define OPENSHOT_ANIMATED_CURVE_H
#include "Json.h"
#include "KeyFrame.h"
#include "Point.h"
#include <cstdint>
#include <string>
#include <vector>
namespace openshot {
class AnimatedCurveNode {
public:
int id;
Keyframe x;
Keyframe y;
Keyframe left_handle_x;
Keyframe left_handle_y;
Keyframe right_handle_x;
Keyframe right_handle_y;
InterpolationType interpolation;
HandleType handle_type;
AnimatedCurveNode();
AnimatedCurveNode(int node_id, double node_x, double node_y, InterpolationType node_interpolation=LINEAR);
Point Evaluate(int64_t frame_number, double x_scale=1.0) const;
std::string Json() const;
Json::Value JsonValue() const;
void SetJson(const std::string value);
void SetJsonValue(const Json::Value& root);
};
class AnimatedCurve {
private:
std::vector<AnimatedCurveNode> nodes;
public:
Keyframe enabled;
AnimatedCurve();
const std::vector<AnimatedCurveNode>& Nodes() const { return nodes; }
std::vector<AnimatedCurveNode>& Nodes() { return nodes; }
Keyframe BuildCurve(int64_t frame_number, double x_scale=1.0) const;
float Sample(float input, int64_t frame_number) const;
std::string Summary(int64_t frame_number) const;
std::string Json() const;
Json::Value JsonValue() const;
void SetJson(const std::string value);
void SetJsonValue(const Json::Value& root);
};
}
#endif
+1
View File
@@ -47,6 +47,7 @@ add_feature_info("IWYU (include-what-you-use)" ENABLE_IWYU "Scan all source file
# Main library sources
set(OPENSHOT_SOURCES
AudioBufferSource.cpp
AnimatedCurve.cpp
AudioDevices.cpp
AudioReaderSource.cpp
AudioResampler.cpp
+88 -131
View File
@@ -44,10 +44,10 @@ static std::string color_to_hex(const QColor& color) {
return color.name(QColor::HexRgb).toStdString();
}
static std::array<float, 256> build_curve_lut(const ColorGradeCurveData& curve) {
static std::array<float, 256> build_curve_lut(const AnimatedCurve& curve, int64_t frame_number) {
std::array<float, 256> lut{};
for (size_t i = 0; i < lut.size(); ++i) {
lut[i] = curve.Sample(static_cast<float>(i) * kInv255);
lut[i] = curve.Sample(static_cast<float>(i) * kInv255, frame_number);
}
return lut;
}
@@ -57,116 +57,65 @@ static float sample_curve_lut(const std::array<float, 256>& lut, float value) {
}
}
ColorGradeCurveData::ColorGradeCurveData()
: enabled(true), points{{0.0f, 0.0f}, {1.0f, 1.0f}} {}
Json::Value ColorGradeCurveData::JsonValue() const {
Json::Value root(Json::objectValue);
root["enabled"] = enabled;
root["points"] = Json::Value(Json::arrayValue);
for (const auto& point : points) {
Json::Value item(Json::objectValue);
item["x"] = point.first;
item["y"] = point.second;
root["points"].append(item);
}
return root;
}
void ColorGradeCurveData::SetJsonValue(const Json::Value& root) {
if (!root["enabled"].isNull())
enabled = root["enabled"].asBool();
std::vector<std::pair<float, float>> parsed;
const Json::Value& json_points = root["points"];
if (json_points.isArray()) {
for (const auto& point : json_points) {
if (!point["x"].isNull() && !point["y"].isNull()) {
parsed.emplace_back(
clamp01(point["x"].asFloat()),
clamp01(point["y"].asFloat()));
}
}
}
if (parsed.empty()) {
parsed = {{0.0f, 0.0f}, {1.0f, 1.0f}};
}
std::sort(parsed.begin(), parsed.end(), [](const auto& lhs, const auto& rhs) {
return lhs.first < rhs.first;
});
if (parsed.front().first > 0.0f) {
parsed.insert(parsed.begin(), {0.0f, parsed.front().second});
} else {
parsed.front().first = 0.0f;
}
if (parsed.back().first < 1.0f) {
parsed.push_back({1.0f, parsed.back().second});
} else {
parsed.back().first = 1.0f;
}
points.swap(parsed);
}
float ColorGradeCurveData::Sample(float input) const {
if (!enabled)
return clamp01(input);
const float x = clamp01(input);
if (points.empty())
return x;
if (x <= points.front().first)
return points.front().second;
if (x >= points.back().first)
return points.back().second;
for (size_t i = 1; i < points.size(); ++i) {
const auto& left = points[i - 1];
const auto& right = points[i];
if (x <= right.first) {
const float span = std::max(0.00001f, right.first - left.first);
const float t = (x - left.first) / span;
return left.second + ((right.second - left.second) * t);
}
}
return points.back().second;
}
std::string ColorGradeCurveData::Summary() const {
std::ostringstream ss;
if (!enabled)
ss << "Disabled, ";
ss << points.size() << " pts";
return ss.str();
}
ColorGradeWheelEntry::ColorGradeWheelEntry()
: color(Qt::white), amount(0.0f), luma(0.0f) {}
: color(QColor(Qt::white)), amount(0.0f), luma(0.0f) {}
Json::Value ColorGradeWheelEntry::JsonValue() const {
Json::Value root(Json::objectValue);
root["color"] = color_to_hex(color);
root["amount"] = amount;
root["luma"] = luma;
root["color"] = color_to_hex(QColor(
color.red.GetInt(1),
color.green.GetInt(1),
color.blue.GetInt(1),
color.alpha.GetInt(1)));
root["color_keyframes"] = color.JsonValue();
root["amount"] = amount.GetValue(1);
root["amount_keyframes"] = amount.JsonValue();
root["luma"] = luma.GetValue(1);
root["luma_keyframes"] = luma.JsonValue();
return root;
}
void ColorGradeWheelEntry::SetJsonValue(const Json::Value& root) {
if (!root["color"].isNull()) {
if (!root["color_keyframes"].isNull()) {
color.SetJsonValue(root["color_keyframes"]);
} else if (!root["color"].isNull()) {
const QColor parsed(QString::fromStdString(root["color"].asString()));
if (parsed.isValid())
color = parsed;
color = Color(parsed);
}
if (!root["amount"].isNull())
amount = std::max(-1.0f, std::min(1.0f, root["amount"].asFloat()));
if (!root["luma"].isNull())
luma = std::max(-1.0f, std::min(1.0f, root["luma"].asFloat()));
if (!root["amount_keyframes"].isNull())
amount.SetJsonValue(root["amount_keyframes"]);
else if (!root["amount"].isNull())
amount = Keyframe(std::max(-1.0f, std::min(1.0f, root["amount"].asFloat())));
if (!root["luma_keyframes"].isNull())
luma.SetJsonValue(root["luma_keyframes"]);
else if (!root["luma"].isNull())
luma = Keyframe(std::max(-1.0f, std::min(1.0f, root["luma"].asFloat())));
}
QColor ColorGradeWheelEntry::GetColor(int64_t frame_number) const {
return QColor(
color.red.GetInt(frame_number),
color.green.GetInt(frame_number),
color.blue.GetInt(frame_number),
color.alpha.GetInt(frame_number));
}
float ColorGradeWheelEntry::GetAmount(int64_t frame_number) const {
return std::max(-1.0f, std::min(1.0f, static_cast<float>(amount.GetValue(frame_number))));
}
float ColorGradeWheelEntry::GetLuma(int64_t frame_number) const {
return std::max(-1.0f, std::min(1.0f, static_cast<float>(luma.GetValue(frame_number))));
}
ColorGradeWheelsData::ColorGradeWheelsData()
: enabled(true) {}
: enabled(1.0) {}
Json::Value ColorGradeWheelsData::JsonValue() const {
Json::Value root(Json::objectValue);
root["enabled"] = enabled;
root["enabled"] = enabled.GetValue(1) >= 0.5;
root["enabled_keyframes"] = enabled.JsonValue();
root["global"] = global.JsonValue();
root["shadows"] = shadows.JsonValue();
root["midtones"] = midtones.JsonValue();
@@ -175,8 +124,10 @@ Json::Value ColorGradeWheelsData::JsonValue() const {
}
void ColorGradeWheelsData::SetJsonValue(const Json::Value& root) {
if (!root["enabled"].isNull())
enabled = root["enabled"].asBool();
if (root["enabled"].isBool())
enabled = Keyframe(root["enabled"].asBool() ? 1.0 : 0.0);
else if (!root["enabled_keyframes"].isNull())
enabled.SetJsonValue(root["enabled_keyframes"]);
if (!root["global"].isNull())
global.SetJsonValue(root["global"]);
if (!root["shadows"].isNull())
@@ -187,9 +138,13 @@ void ColorGradeWheelsData::SetJsonValue(const Json::Value& root) {
highlights.SetJsonValue(root["highlights"]);
}
std::string ColorGradeWheelsData::Summary() const {
return enabled ? "Global / Shadows / Midtones / Highlights"
: "Disabled";
std::string ColorGradeWheelsData::Summary(int64_t frame_number) const {
return IsEnabled(frame_number) ? "Global / Shadows / Midtones / Highlights"
: "Disabled";
}
bool ColorGradeWheelsData::IsEnabled(int64_t frame_number) const {
return enabled.GetValue(frame_number) >= 0.5;
}
ColorGrade::ColorGrade()
@@ -252,10 +207,10 @@ std::shared_ptr<openshot::Frame> ColorGrade::GetFrame(std::shared_ptr<openshot::
const float mix_value = std::max(0.0f, std::min(1.0f, static_cast<float>(mix.GetValue(frame_number))));
const float exposure_gain = std::pow(2.0f, exposure_value);
const float contrast_factor = std::max(0.0f, 1.0f + contrast_value);
const std::array<float, 256> curve_master_lut = build_curve_lut(curve_master);
const std::array<float, 256> curve_red_lut = build_curve_lut(curve_red);
const std::array<float, 256> curve_green_lut = build_curve_lut(curve_green);
const std::array<float, 256> curve_blue_lut = build_curve_lut(curve_blue);
const std::array<float, 256> curve_all_lut = build_curve_lut(curve_all, frame_number);
const std::array<float, 256> curve_red_lut = build_curve_lut(curve_red, frame_number);
const std::array<float, 256> curve_green_lut = build_curve_lut(curve_green, frame_number);
const std::array<float, 256> curve_blue_lut = build_curve_lut(curve_blue, frame_number);
static const std::array<float, 256> inv_alpha = [] {
std::array<float, 256> lut{};
@@ -265,17 +220,19 @@ std::shared_ptr<openshot::Frame> ColorGrade::GetFrame(std::shared_ptr<openshot::
return lut;
}();
const auto wheel_bias = [](const ColorGradeWheelEntry& wheel, float weight, float& r, float& g, float& b) {
const auto wheel_bias = [frame_number](const ColorGradeWheelEntry& wheel, float weight, float& r, float& g, float& b) {
if (std::abs(weight) <= 0.00001f)
return;
const float cr = wheel.color.redF();
const float cg = wheel.color.greenF();
const float cb = wheel.color.blueF();
const QColor color = wheel.GetColor(frame_number);
const float cr = color.redF();
const float cg = color.greenF();
const float cb = color.blueF();
const float avg = (cr + cg + cb) / 3.0f;
const float amount = wheel.amount * weight;
r += ((cr - avg) * amount) + (wheel.luma * weight);
g += ((cg - avg) * amount) + (wheel.luma * weight);
b += ((cb - avg) * amount) + (wheel.luma * weight);
const float amount = wheel.GetAmount(frame_number) * weight;
const float luma = wheel.GetLuma(frame_number) * weight;
r += ((cr - avg) * amount) + luma;
g += ((cg - avg) * amount) + luma;
b += ((cb - avg) * amount) + luma;
};
unsigned char* pixels = reinterpret_cast<unsigned char*>(frame_image->bits());
@@ -340,7 +297,7 @@ std::shared_ptr<openshot::Frame> ColorGrade::GetFrame(std::shared_ptr<openshot::
float wheel_r = R;
float wheel_g = G;
float wheel_b = B;
if (wheels.enabled) {
if (wheels.IsEnabled(frame_number)) {
wheel_bias(wheels.global, 1.0f, wheel_r, wheel_g, wheel_b);
wheel_bias(wheels.shadows, (1.0f - luma) * (1.0f - luma), wheel_r, wheel_g, wheel_b);
wheel_bias(wheels.midtones, smooth_midtones(luma), wheel_r, wheel_g, wheel_b);
@@ -362,9 +319,9 @@ std::shared_ptr<openshot::Frame> ColorGrade::GetFrame(std::shared_ptr<openshot::
B = Clamp01(luma + ((B - luma) * sat_factor));
// Curves.
R = sample_curve_lut(curve_master_lut, R);
G = sample_curve_lut(curve_master_lut, G);
B = sample_curve_lut(curve_master_lut, B);
R = sample_curve_lut(curve_all_lut, R);
G = sample_curve_lut(curve_all_lut, G);
B = sample_curve_lut(curve_all_lut, B);
R = sample_curve_lut(curve_red_lut, R);
G = sample_curve_lut(curve_green_lut, G);
B = sample_curve_lut(curve_blue_lut, B);
@@ -410,7 +367,7 @@ Json::Value ColorGrade::JsonValue() const {
root["vibrance"] = vibrance.JsonValue();
root["mix"] = mix.JsonValue();
root["wheels"] = wheels.JsonValue();
root["curve_master"] = curve_master.JsonValue();
root["curve_all"] = curve_all.JsonValue();
root["curve_red"] = curve_red.JsonValue();
root["curve_green"] = curve_green.JsonValue();
root["curve_blue"] = curve_blue.JsonValue();
@@ -451,8 +408,8 @@ void ColorGrade::SetJsonValue(const Json::Value root) {
mix.SetJsonValue(root["mix"]);
if (!root["wheels"].isNull())
wheels.SetJsonValue(root["wheels"]);
if (!root["curve_master"].isNull())
curve_master.SetJsonValue(root["curve_master"]);
if (!root["curve_all"].isNull())
curve_all.SetJsonValue(root["curve_all"]);
if (!root["curve_red"].isNull())
curve_red.SetJsonValue(root["curve_red"]);
if (!root["curve_green"].isNull())
@@ -482,29 +439,29 @@ std::string ColorGrade::PropertiesJSON(int64_t requested_frame) const {
root["vibrance"] = add_property_json("Vibrance", vibrance.GetValue(requested_frame), "float", "", &vibrance, -1.0, 1.0, false, requested_frame);
root["mix"] = add_property_json("Mix", mix.GetValue(requested_frame), "float", "", &mix, 0.0, 1.0, false, requested_frame);
root["wheels"] = add_property_json("Color Wheels", 0.0, "colorgrade_wheels", wheels.Summary(), NULL, 0.0, 1.0, false, requested_frame);
root["wheels"] = add_property_json("Color Wheels", 0.0, "colorgrade_wheels", wheels.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame);
root["wheels"]["wheels"] = wheels.JsonValue();
root["wheels"]["summary"] = wheels.Summary();
root["wheels"]["summary"] = wheels.Summary(requested_frame);
root["curve_master"] = add_property_json("Curve: Master", 0.0, "colorgrade_curve", curve_master.Summary(), NULL, 0.0, 1.0, false, requested_frame);
root["curve_master"]["curve"] = curve_master.JsonValue();
root["curve_master"]["channel"] = "master";
root["curve_master"]["summary"] = curve_master.Summary();
root["curve_all"] = add_property_json("Curve: All", 0.0, "colorgrade_curve", curve_all.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame);
root["curve_all"]["curve"] = curve_all.JsonValue();
root["curve_all"]["channel"] = "all";
root["curve_all"]["summary"] = curve_all.Summary(requested_frame);
root["curve_red"] = add_property_json("Curve: Red", 0.0, "colorgrade_curve", curve_red.Summary(), NULL, 0.0, 1.0, false, requested_frame);
root["curve_red"] = add_property_json("Curve: Red", 0.0, "colorgrade_curve", curve_red.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame);
root["curve_red"]["curve"] = curve_red.JsonValue();
root["curve_red"]["channel"] = "red";
root["curve_red"]["summary"] = curve_red.Summary();
root["curve_red"]["summary"] = curve_red.Summary(requested_frame);
root["curve_green"] = add_property_json("Curve: Green", 0.0, "colorgrade_curve", curve_green.Summary(), NULL, 0.0, 1.0, false, requested_frame);
root["curve_green"] = add_property_json("Curve: Green", 0.0, "colorgrade_curve", curve_green.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame);
root["curve_green"]["curve"] = curve_green.JsonValue();
root["curve_green"]["channel"] = "green";
root["curve_green"]["summary"] = curve_green.Summary();
root["curve_green"]["summary"] = curve_green.Summary(requested_frame);
root["curve_blue"] = add_property_json("Curve: Blue", 0.0, "colorgrade_curve", curve_blue.Summary(), NULL, 0.0, 1.0, false, requested_frame);
root["curve_blue"] = add_property_json("Curve: Blue", 0.0, "colorgrade_curve", curve_blue.Summary(requested_frame), NULL, 0.0, 1.0, false, requested_frame);
root["curve_blue"]["curve"] = curve_blue.JsonValue();
root["curve_blue"]["channel"] = "blue";
root["curve_blue"]["summary"] = curve_blue.Summary();
root["curve_blue"]["summary"] = curve_blue.Summary(requested_frame);
root["lut_path"] = add_property_json("LUT File", 0.0, "string", lut_path, NULL, 0, 0, false, requested_frame);
root["lut_intensity"] = add_property_json("LUT Intensity", lut_intensity.GetValue(requested_frame), "float", "", &lut_intensity, 0.0, 1.0, false, requested_frame);
+16 -39
View File
@@ -13,7 +13,9 @@
#ifndef OPENSHOT_COLOR_GRADE_EFFECT_H
#define OPENSHOT_COLOR_GRADE_EFFECT_H
#include "../AnimatedCurve.h"
#include "../EffectBase.h"
#include "../Color.h"
#include "../Frame.h"
#include "../Json.h"
#include "../KeyFrame.h"
@@ -29,52 +31,26 @@
namespace openshot
{
/**
* @brief A compact rich curve payload used by the ColorGrade effect.
*
* The JSON form is:
* {
* "points": [
* {"x": 0.0, "y": 0.0},
* {"x": 1.0, "y": 1.0}
* ]
* }
*/
struct ColorGradeCurveData {
bool enabled;
std::vector<std::pair<float, float>> points;
ColorGradeCurveData();
Json::Value JsonValue() const;
void SetJsonValue(const Json::Value& root);
float Sample(float input) const;
std::string Summary() const;
};
/**
* @brief Wheel payload for a tonal region.
*
* The JSON form is:
* {
* "color": "#RRGGBB",
* "amount": 0.0,
* "luma": 0.0
* }
* @brief Wheel payload for a tonal region using native animated primitives.
*/
struct ColorGradeWheelEntry {
QColor color;
float amount;
float luma;
Color color;
Keyframe amount;
Keyframe luma;
ColorGradeWheelEntry();
Json::Value JsonValue() const;
void SetJsonValue(const Json::Value& root);
QColor GetColor(int64_t frame_number) const;
float GetAmount(int64_t frame_number) const;
float GetLuma(int64_t frame_number) const;
};
/**
* @brief All wheel controls for ColorGrade.
*/
struct ColorGradeWheelsData {
bool enabled;
Keyframe enabled;
ColorGradeWheelEntry global;
ColorGradeWheelEntry shadows;
ColorGradeWheelEntry midtones;
@@ -83,7 +59,8 @@ namespace openshot
ColorGradeWheelsData();
Json::Value JsonValue() const;
void SetJsonValue(const Json::Value& root);
std::string Summary() const;
std::string Summary(int64_t frame_number) const;
bool IsEnabled(int64_t frame_number) const;
};
/**
@@ -118,10 +95,10 @@ namespace openshot
Keyframe lut_intensity;
ColorGradeWheelsData wheels;
ColorGradeCurveData curve_master;
ColorGradeCurveData curve_red;
ColorGradeCurveData curve_green;
ColorGradeCurveData curve_blue;
AnimatedCurve curve_all;
AnimatedCurve curve_red;
AnimatedCurve curve_green;
AnimatedCurve curve_blue;
ColorGrade();
+105
View File
@@ -0,0 +1,105 @@
/**
* @file
* @brief Unit tests for AnimatedCurve classes
*
* @ref License
*/
// Copyright (c) 2008-2026 OpenShot Studios, LLC
//
// SPDX-License-Identifier: LGPL-3.0-or-later
#include "AnimatedCurve.h"
#include "openshot_catch.h"
using namespace openshot;
TEST_CASE("AnimatedCurve defaults to identity curve", "[animatedcurve]")
{
AnimatedCurve curve;
CHECK(curve.Nodes().size() == 2);
CHECK(curve.Sample(0.0f, 1) == Approx(0.0f));
CHECK(curve.Sample(0.5f, 1) == Approx(0.5f).margin(0.01f));
CHECK(curve.Sample(1.0f, 1) == Approx(1.0f));
}
TEST_CASE("AnimatedCurve interpolates node coordinates over time", "[animatedcurve][animation]")
{
AnimatedCurve curve;
curve.Nodes().clear();
AnimatedCurveNode left(10, 0.0, 0.0, LINEAR);
left.y.AddPoint(300, 0.5, LINEAR);
AnimatedCurveNode right(20, 1.0, 1.0, LINEAR);
right.y = Keyframe(1.0);
right.y.AddPoint(300, 0.75, LINEAR);
curve.Nodes().push_back(left);
curve.Nodes().push_back(right);
CHECK(curve.Sample(0.0f, 1) == Approx(0.0f).margin(0.01f));
CHECK(curve.Sample(0.0f, 300) == Approx(0.5f).margin(0.01f));
CHECK(curve.Sample(1.0f, 1) == Approx(1.0f).margin(0.01f));
CHECK(curve.Sample(1.0f, 300) == Approx(0.75f).margin(0.01f));
const float midpoint = curve.Sample(0.5f, 150);
CHECK(midpoint > 0.35f);
CHECK(midpoint < 0.75f);
}
TEST_CASE("AnimatedCurve preserves bezier handles on evaluated runtime curve", "[animatedcurve][bezier]")
{
AnimatedCurve curve;
curve.Nodes().clear();
AnimatedCurveNode left(1, 0.0, 0.0, BEZIER);
left.right_handle_x = Keyframe(0.2);
left.right_handle_y = Keyframe(0.0);
AnimatedCurveNode middle(2, 0.5, 0.8, BEZIER);
middle.left_handle_x = Keyframe(0.8);
middle.left_handle_y = Keyframe(1.0);
middle.right_handle_x = Keyframe(0.2);
middle.right_handle_y = Keyframe(0.0);
AnimatedCurveNode right(3, 1.0, 1.0, BEZIER);
right.left_handle_x = Keyframe(0.8);
right.left_handle_y = Keyframe(1.0);
curve.Nodes().push_back(left);
curve.Nodes().push_back(middle);
curve.Nodes().push_back(right);
const Keyframe runtime_curve = curve.BuildCurve(1, 255.0);
CHECK(runtime_curve.GetCount() == 3);
CHECK(runtime_curve.GetPoint(1).interpolation == BEZIER);
CHECK(runtime_curve.GetPoint(1).handle_left.X == Approx(0.8f));
CHECK(runtime_curve.GetPoint(1).handle_right.X == Approx(0.2f));
CHECK(curve.Sample(0.5f, 1) == Approx(0.8f).margin(0.05f));
}
TEST_CASE("AnimatedCurve JSON round trip preserves animated nodes", "[animatedcurve][json]")
{
AnimatedCurve curve;
curve.enabled = Keyframe(1.0);
curve.enabled.AddPoint(50, 0.0, LINEAR);
curve.Nodes().clear();
AnimatedCurveNode left(100, 0.0, 0.0, LINEAR);
left.y.AddPoint(50, 0.25, LINEAR);
AnimatedCurveNode right(200, 1.0, 1.0, LINEAR);
right.x = Keyframe(1.0);
right.x.AddPoint(50, 0.9, LINEAR);
curve.Nodes().push_back(left);
curve.Nodes().push_back(right);
AnimatedCurve copy;
copy.SetJson(curve.Json());
CHECK(copy.Nodes().size() == 2);
CHECK(copy.Nodes()[0].id == 100);
CHECK(copy.Nodes()[0].y.GetValue(50) == Approx(0.25));
CHECK(copy.Nodes()[1].x.GetValue(50) == Approx(0.9));
CHECK(copy.enabled.GetValue(50) == Approx(0.0));
}
+1
View File
@@ -26,6 +26,7 @@ target_link_libraries(openshot-benchmark openshot)
###
set(OPENSHOT_TESTS
AudioDeviceManager
AnimatedCurve
AudioWaveformer
CacheDisk
CacheMemory
+147 -108
View File
@@ -47,6 +47,21 @@ static std::string lutPath()
return path.str();
}
static AnimatedCurve makeBrightCurve()
{
AnimatedCurve curve;
curve.Nodes().clear();
AnimatedCurveNode left(0, 0.0, 0.0, LINEAR);
AnimatedCurveNode middle(1, 0.5, 0.8, LINEAR);
AnimatedCurveNode right(2, 1.0, 1.0, LINEAR);
curve.Nodes().push_back(left);
curve.Nodes().push_back(middle);
curve.Nodes().push_back(right);
return curve;
}
TEST_CASE("Default ColorGrade leaves image unchanged", "[effect][colorgrade]")
{
ColorGrade effect;
@@ -57,73 +72,32 @@ TEST_CASE("Default ColorGrade leaves image unchanged", "[effect][colorgrade]")
CHECK(after == before);
}
TEST_CASE("ColorGrade JSON round-trip preserves wheels and curves", "[effect][colorgrade][json]")
TEST_CASE("ColorGrade JSON round-trip preserves animated wheels and curves", "[effect][colorgrade][json]")
{
ColorGrade effect;
effect.temperature = Keyframe(0.25);
effect.mix = Keyframe(0.75);
Json::Value wheels(Json::objectValue);
wheels["enabled"] = false;
wheels["global"]["color"] = "#ff8800";
wheels["global"]["amount"] = 0.4;
wheels["global"]["luma"] = 0.1;
effect.wheels.SetJsonValue(wheels);
Json::Value curve(Json::objectValue);
curve["enabled"] = false;
curve["points"] = Json::Value(Json::arrayValue);
Json::Value p0(Json::objectValue);
p0["x"] = 0.0;
p0["y"] = 0.0;
Json::Value p1(Json::objectValue);
p1["x"] = 0.5;
p1["y"] = 0.75;
Json::Value p2(Json::objectValue);
p2["x"] = 1.0;
p2["y"] = 1.0;
curve["points"].append(p0);
curve["points"].append(p1);
curve["points"].append(p2);
effect.curve_master.SetJsonValue(curve);
effect.SetJsonValue(Json::Value(Json::objectValue));
effect.wheels.enabled = Keyframe(0.0);
effect.wheels.global.color = Color("#ff8800");
effect.wheels.global.amount = Keyframe(0.4);
effect.wheels.global.luma = Keyframe(0.1);
effect.curve_all = makeBrightCurve();
effect.curve_all.enabled = Keyframe(0.0);
effect.curve_all.Nodes()[1].y.AddPoint(12, 0.9, LINEAR);
ColorGrade copy;
copy.SetJson(effect.Json());
CHECK(copy.temperature.GetValue(0) == Approx(0.25));
CHECK(copy.mix.GetValue(0) == Approx(0.75));
CHECK(copy.JsonValue()["wheels"]["enabled"].asBool() == false);
CHECK(copy.temperature.GetValue(1) == Approx(0.25));
CHECK(copy.mix.GetValue(1) == Approx(0.75));
CHECK(copy.wheels.enabled.GetValue(1) == Approx(0.0));
CHECK(copy.JsonValue()["wheels"]["global"]["color"].asString() == "#ff8800");
CHECK(copy.JsonValue()["curve_master"]["enabled"].asBool() == false);
CHECK(copy.JsonValue()["curve_master"]["points"].size() == 3);
CHECK(copy.JsonValue()["curve_all"]["nodes"].size() == 3);
CHECK(copy.curve_all.Nodes()[1].y.GetValue(12) == Approx(0.9));
CHECK(copy.curve_all.enabled.GetValue(1) == Approx(0.0));
}
TEST_CASE("ColorGrade defaults missing enabled flags to true", "[effect][colorgrade][json]")
{
ColorGrade effect;
Json::Value wheels(Json::objectValue);
wheels["global"]["color"] = "#3366ff";
wheels["global"]["amount"] = 0.5;
effect.wheels.SetJsonValue(wheels);
Json::Value curve(Json::objectValue);
curve["points"] = Json::Value(Json::arrayValue);
Json::Value p0(Json::objectValue);
p0["x"] = 0.0;
p0["y"] = 0.0;
Json::Value p1(Json::objectValue);
p1["x"] = 1.0;
p1["y"] = 1.0;
curve["points"].append(p0);
curve["points"].append(p1);
effect.curve_master.SetJsonValue(curve);
CHECK(effect.wheels.JsonValue()["enabled"].asBool() == true);
CHECK(effect.curve_master.JsonValue()["enabled"].asBool() == true);
}
TEST_CASE("PropertiesJSON exposes rich wheels and curve properties", "[effect][colorgrade][ui]")
TEST_CASE("PropertiesJSON exposes animated wheels and curve properties", "[effect][colorgrade][ui]")
{
ColorGrade effect;
std::istringstream props(effect.PropertiesJSON(1));
@@ -135,30 +109,17 @@ TEST_CASE("PropertiesJSON exposes rich wheels and curve properties", "[effect][c
CHECK(root["wheels"]["type"].asString() == "colorgrade_wheels");
CHECK(root["wheels"].isMember("wheels"));
CHECK(root["wheels"]["wheels"]["enabled"].asBool() == true);
CHECK(root.isMember("curve_master"));
CHECK(root["curve_master"]["type"].asString() == "colorgrade_curve");
CHECK(root["curve_master"].isMember("curve"));
CHECK(root["curve_master"]["curve"]["enabled"].asBool() == true);
CHECK(root.isMember("curve_all"));
CHECK(root["curve_all"]["type"].asString() == "colorgrade_curve");
CHECK(root["curve_all"].isMember("curve"));
CHECK(root["curve_all"]["curve"]["enabled"].isObject());
CHECK(root["curve_all"]["curve"]["nodes"].size() == 2);
}
TEST_CASE("Curve data brightens matching pixels", "[effect][colorgrade][curve]")
TEST_CASE("Animated curve data brightens matching pixels", "[effect][colorgrade][curve]")
{
ColorGrade effect;
Json::Value curve(Json::objectValue);
curve["points"] = Json::Value(Json::arrayValue);
Json::Value p0(Json::objectValue);
p0["x"] = 0.0;
p0["y"] = 0.0;
Json::Value p1(Json::objectValue);
p1["x"] = 0.5;
p1["y"] = 0.8;
Json::Value p2(Json::objectValue);
p2["x"] = 1.0;
p2["y"] = 1.0;
curve["points"].append(p0);
curve["points"].append(p1);
curve["points"].append(p2);
effect.curve_master.SetJsonValue(curve);
effect.curve_all = makeBrightCurve();
auto frame = makeGradeFrame();
const QColor before = frame->GetImage()->pixelColor(0, 0);
@@ -167,25 +128,72 @@ TEST_CASE("Curve data brightens matching pixels", "[effect][colorgrade][curve]")
CHECK(after.red() > before.red());
}
TEST_CASE("Disabled curve preserves points but renders as identity", "[effect][colorgrade][curve]")
TEST_CASE("Animated curve data supports smooth bezier points", "[effect][colorgrade][curve][bezier]")
{
ColorGrade effect;
Json::Value curve(Json::objectValue);
curve["enabled"] = false;
curve["points"] = Json::Value(Json::arrayValue);
Json::Value p0(Json::objectValue);
p0["x"] = 0.0;
p0["y"] = 0.2;
Json::Value p1(Json::objectValue);
p1["x"] = 0.5;
p1["y"] = 0.9;
Json::Value p2(Json::objectValue);
p2["x"] = 1.0;
p2["y"] = 1.0;
curve["points"].append(p0);
curve["points"].append(p1);
curve["points"].append(p2);
effect.curve_master.SetJsonValue(curve);
effect.curve_all.Nodes().clear();
AnimatedCurveNode left(0, 0.0, 0.0, BEZIER);
left.right_handle_x = Keyframe(0.2);
left.right_handle_y = Keyframe(0.0);
AnimatedCurveNode middle(1, 0.5, 0.8, BEZIER);
middle.left_handle_x = Keyframe(0.8);
middle.left_handle_y = Keyframe(1.0);
middle.right_handle_x = Keyframe(0.2);
middle.right_handle_y = Keyframe(0.0);
AnimatedCurveNode right(2, 1.0, 1.0, BEZIER);
right.left_handle_x = Keyframe(0.8);
right.left_handle_y = Keyframe(1.0);
effect.curve_all.Nodes().push_back(left);
effect.curve_all.Nodes().push_back(middle);
effect.curve_all.Nodes().push_back(right);
auto frame = makeGradeFrame();
const QColor before = frame->GetImage()->pixelColor(0, 0);
auto out = effect.GetFrame(frame, 1);
const QColor after = out->GetImage()->pixelColor(0, 0);
CHECK(after.red() > before.red());
}
TEST_CASE("Animated curve nodes interpolate over time", "[effect][colorgrade][curve][animation]")
{
ColorGrade effect;
effect.curve_all.Nodes().clear();
AnimatedCurveNode left(10, 0.0, 0.0, LINEAR);
left.y.AddPoint(300, 0.5, LINEAR);
AnimatedCurveNode right(20, 1.0, 1.0, LINEAR);
right.y.AddPoint(300, 0.75, LINEAR);
effect.curve_all.Nodes().push_back(left);
effect.curve_all.Nodes().push_back(right);
CHECK(effect.curve_all.Sample(0.0f, 1) == Approx(0.0f).margin(0.01f));
CHECK(effect.curve_all.Sample(0.0f, 300) == Approx(0.5f).margin(0.01f));
CHECK(effect.curve_all.Sample(1.0f, 1) == Approx(1.0f).margin(0.01f));
CHECK(effect.curve_all.Sample(1.0f, 300) == Approx(0.75f).margin(0.01f));
auto frame_a = makeGradeFrame();
auto frame_b = makeGradeFrame();
auto frame_mid = makeGradeFrame();
const int before = frame_a->GetImage()->pixelColor(0, 0).red();
const int after_a = effect.GetFrame(frame_a, 1)->GetImage()->pixelColor(0, 0).red();
const int after_b = effect.GetFrame(frame_b, 300)->GetImage()->pixelColor(0, 0).red();
const int after_mid = effect.GetFrame(frame_mid, 150)->GetImage()->pixelColor(0, 0).red();
CHECK(after_b != before);
CHECK(after_mid != after_a);
CHECK(after_mid != after_b);
}
TEST_CASE("Disabled animated curve renders as identity", "[effect][colorgrade][curve]")
{
ColorGrade effect;
effect.curve_all = makeBrightCurve();
effect.curve_all.enabled = Keyframe(0.0);
auto frame = makeGradeFrame();
const QColor before = frame->GetImage()->pixelColor(0, 0);
@@ -193,18 +201,15 @@ TEST_CASE("Disabled curve preserves points but renders as identity", "[effect][c
const QColor after = out->GetImage()->pixelColor(0, 0);
CHECK(after == before);
CHECK(effect.curve_master.JsonValue()["enabled"].asBool() == false);
CHECK(effect.curve_master.JsonValue()["points"].size() == 3);
CHECK(effect.curve_all.JsonValue()["nodes"].size() == 3);
}
TEST_CASE("Wheel data shifts tonal balance", "[effect][colorgrade][wheels]")
{
ColorGrade effect;
Json::Value wheels(Json::objectValue);
wheels["shadows"]["color"] = "#ff6600";
wheels["shadows"]["amount"] = 0.8;
wheels["shadows"]["luma"] = 0.1;
effect.wheels.SetJsonValue(wheels);
effect.wheels.shadows.color = Color("#ff6600");
effect.wheels.shadows.amount = Keyframe(0.8);
effect.wheels.shadows.luma = Keyframe(0.1);
auto frame = makeGradeFrame();
const QColor before = frame->GetImage()->pixelColor(0, 0);
@@ -213,15 +218,49 @@ TEST_CASE("Wheel data shifts tonal balance", "[effect][colorgrade][wheels]")
CHECK(after != before);
}
TEST_CASE("Wheel data interpolates over time with native keyframes", "[effect][colorgrade][wheels][animation]")
{
ColorGrade effect;
effect.wheels.enabled = Keyframe(1.0);
effect.wheels.global.color.red = Keyframe(255.0);
effect.wheels.global.color.green = Keyframe(255.0);
effect.wheels.global.color.green.AddPoint(11, 0, LINEAR);
effect.wheels.global.color.blue = Keyframe(255.0);
effect.wheels.global.color.blue.AddPoint(11, 0, LINEAR);
effect.wheels.global.color.alpha = Keyframe(255.0);
effect.wheels.global.amount = Keyframe(0.0);
effect.wheels.global.luma = Keyframe(0.0);
effect.wheels.global.luma.AddPoint(11, 0.3, LINEAR);
const QColor wheel_a = effect.wheels.global.GetColor(1);
const QColor wheel_mid = effect.wheels.global.GetColor(6);
const QColor wheel_b = effect.wheels.global.GetColor(11);
CHECK(wheel_a.green() == 255);
CHECK(wheel_b.green() == 0);
CHECK(wheel_mid.green() < wheel_a.green());
CHECK(wheel_mid.green() > wheel_b.green());
auto frame_a = makeGradeFrame();
auto frame_b = makeGradeFrame();
auto frame_mid = makeGradeFrame();
const int before = frame_a->GetImage()->pixelColor(0, 0).red();
const int after_a = effect.GetFrame(frame_a, 1)->GetImage()->pixelColor(0, 0).red();
const int after_b = effect.GetFrame(frame_b, 11)->GetImage()->pixelColor(0, 0).red();
const int after_mid = effect.GetFrame(frame_mid, 6)->GetImage()->pixelColor(0, 0).red();
CHECK(after_a == before);
CHECK(after_b > before);
CHECK(after_mid > after_a);
CHECK(after_mid < after_b);
}
TEST_CASE("Disabled wheels preserve values but render as identity", "[effect][colorgrade][wheels]")
{
ColorGrade effect;
Json::Value wheels(Json::objectValue);
wheels["enabled"] = false;
wheels["shadows"]["color"] = "#ff6600";
wheels["shadows"]["amount"] = 0.8;
wheels["shadows"]["luma"] = 0.1;
effect.wheels.SetJsonValue(wheels);
effect.wheels.enabled = Keyframe(0.0);
effect.wheels.shadows.color = Color("#ff6600");
effect.wheels.shadows.amount = Keyframe(0.8);
effect.wheels.shadows.luma = Keyframe(0.1);
auto frame = makeGradeFrame();
const QColor before = frame->GetImage()->pixelColor(0, 0);
@@ -229,8 +268,8 @@ TEST_CASE("Disabled wheels preserve values but render as identity", "[effect][co
const QColor after = out->GetImage()->pixelColor(0, 0);
CHECK(after == before);
CHECK(effect.wheels.JsonValue()["enabled"].asBool() == false);
CHECK(effect.wheels.JsonValue()["shadows"]["amount"].asFloat() == Approx(0.8f));
CHECK(effect.wheels.enabled.GetValue(1) == Approx(0.0));
CHECK(effect.wheels.shadows.amount.GetValue(1) == Approx(0.8));
}
TEST_CASE("Identity ColorGrade preserves semi-transparent pixels", "[effect][colorgrade][alpha]")