From 291d3a5bf1bfddbdcee2f0c7aea9f60bcb34d104 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Wed, 22 Apr 2026 15:59:50 -0500 Subject: [PATCH] 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. --- src/AnimatedCurve.cpp | 179 ++++++++++++++++++++++++++ src/AnimatedCurve.h | 73 +++++++++++ src/CMakeLists.txt | 1 + src/effects/ColorGrade.cpp | 219 +++++++++++++------------------ src/effects/ColorGrade.h | 55 +++----- tests/AnimatedCurve.cpp | 105 +++++++++++++++ tests/CMakeLists.txt | 1 + tests/ColorGrade.cpp | 255 +++++++++++++++++++++---------------- 8 files changed, 610 insertions(+), 278 deletions(-) create mode 100644 src/AnimatedCurve.cpp create mode 100644 src/AnimatedCurve.h create mode 100644 tests/AnimatedCurve.cpp diff --git a/src/AnimatedCurve.cpp b/src/AnimatedCurve.cpp new file mode 100644 index 00000000..464bcf78 --- /dev/null +++ b/src/AnimatedCurve.cpp @@ -0,0 +1,179 @@ +/** + * @file + * @brief Source file for AnimatedCurve classes + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "AnimatedCurve.h" +#include "Exceptions.h" + +#include +#include +#include + +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(clamp01(x.GetValue(frame_number)) * x_scale), + static_cast(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(root["interpolation"].asInt()); + if (!root["handle_type"].isNull()) + handle_type = static_cast(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 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(clamp01(input)); + + const Keyframe curve = BuildCurve(frame_number, kCurveDomainMax); + return static_cast(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 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); + } +} diff --git a/src/AnimatedCurve.h b/src/AnimatedCurve.h new file mode 100644 index 00000000..3073967f --- /dev/null +++ b/src/AnimatedCurve.h @@ -0,0 +1,73 @@ +/** + * @file + * @brief Header file for AnimatedCurve classes + * @author Jonathan Thomas + * + * @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 +#include +#include + +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 nodes; + +public: + Keyframe enabled; + + AnimatedCurve(); + + const std::vector& Nodes() const { return nodes; } + std::vector& 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 diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 458dc99b..2827823c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -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 diff --git a/src/effects/ColorGrade.cpp b/src/effects/ColorGrade.cpp index 81526cf9..d8f60d09 100644 --- a/src/effects/ColorGrade.cpp +++ b/src/effects/ColorGrade.cpp @@ -44,10 +44,10 @@ static std::string color_to_hex(const QColor& color) { return color.name(QColor::HexRgb).toStdString(); } -static std::array build_curve_lut(const ColorGradeCurveData& curve) { +static std::array build_curve_lut(const AnimatedCurve& curve, int64_t frame_number) { std::array lut{}; for (size_t i = 0; i < lut.size(); ++i) { - lut[i] = curve.Sample(static_cast(i) * kInv255); + lut[i] = curve.Sample(static_cast(i) * kInv255, frame_number); } return lut; } @@ -57,116 +57,65 @@ static float sample_curve_lut(const std::array& 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> 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(amount.GetValue(frame_number)))); +} + +float ColorGradeWheelEntry::GetLuma(int64_t frame_number) const { + return std::max(-1.0f, std::min(1.0f, static_cast(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 ColorGrade::GetFrame(std::shared_ptr(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 curve_master_lut = build_curve_lut(curve_master); - const std::array curve_red_lut = build_curve_lut(curve_red); - const std::array curve_green_lut = build_curve_lut(curve_green); - const std::array curve_blue_lut = build_curve_lut(curve_blue); + const std::array curve_all_lut = build_curve_lut(curve_all, frame_number); + const std::array curve_red_lut = build_curve_lut(curve_red, frame_number); + const std::array curve_green_lut = build_curve_lut(curve_green, frame_number); + const std::array curve_blue_lut = build_curve_lut(curve_blue, frame_number); static const std::array inv_alpha = [] { std::array lut{}; @@ -265,17 +220,19 @@ std::shared_ptr ColorGrade::GetFrame(std::shared_ptr(frame_image->bits()); @@ -340,7 +297,7 @@ std::shared_ptr ColorGrade::GetFrame(std::shared_ptr ColorGrade::GetFrame(std::shared_ptr> 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(); diff --git a/tests/AnimatedCurve.cpp b/tests/AnimatedCurve.cpp new file mode 100644 index 00000000..859f6da1 --- /dev/null +++ b/tests/AnimatedCurve.cpp @@ -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)); +} diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7a8afbe8..f38ba5f0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -26,6 +26,7 @@ target_link_libraries(openshot-benchmark openshot) ### set(OPENSHOT_TESTS AudioDeviceManager + AnimatedCurve AudioWaveformer CacheDisk CacheMemory diff --git a/tests/ColorGrade.cpp b/tests/ColorGrade.cpp index 652d1571..b018a2ab 100644 --- a/tests/ColorGrade.cpp +++ b/tests/ColorGrade.cpp @@ -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]")