2026-04-17 22:47:12 -05:00
|
|
|
/**
|
|
|
|
|
* @file
|
|
|
|
|
* @brief Unit tests for ColorGrade effect
|
|
|
|
|
*
|
|
|
|
|
* @ref License
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
// Copyright (c) 2008-2026 OpenShot Studios, LLC
|
|
|
|
|
//
|
|
|
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
|
|
|
|
|
|
|
|
#include <memory>
|
|
|
|
|
#include <QColor>
|
|
|
|
|
#include <QImage>
|
|
|
|
|
#include <sstream>
|
|
|
|
|
|
|
|
|
|
#include "Frame.h"
|
|
|
|
|
#include "effects/ColorGrade.h"
|
|
|
|
|
#include "openshot_catch.h"
|
|
|
|
|
|
|
|
|
|
using namespace openshot;
|
|
|
|
|
|
|
|
|
|
static std::shared_ptr<Frame> makeGradeFrame()
|
|
|
|
|
{
|
|
|
|
|
QImage img(2, 2, QImage::Format_ARGB32);
|
|
|
|
|
img.fill(QColor(80, 90, 110, 255));
|
|
|
|
|
img.setPixelColor(0, 0, QColor(64, 96, 160, 255));
|
|
|
|
|
auto frame = std::make_shared<Frame>();
|
|
|
|
|
*frame->GetImage() = img;
|
|
|
|
|
return frame;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::shared_ptr<Frame> makeTransparentGradeFrame()
|
|
|
|
|
{
|
|
|
|
|
QImage img(2, 2, QImage::Format_ARGB32_Premultiplied);
|
|
|
|
|
img.fill(QColor(40, 50, 70, 128));
|
|
|
|
|
img.setPixelColor(0, 0, QColor(64, 96, 128, 128));
|
|
|
|
|
auto frame = std::make_shared<Frame>();
|
|
|
|
|
*frame->GetImage() = img;
|
|
|
|
|
return frame;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static std::string lutPath()
|
|
|
|
|
{
|
|
|
|
|
std::stringstream path;
|
|
|
|
|
path << TEST_MEDIA_PATH << "example-lut.cube";
|
|
|
|
|
return path.str();
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:47:12 -05:00
|
|
|
TEST_CASE("Default ColorGrade leaves image unchanged", "[effect][colorgrade]")
|
|
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
|
|
|
|
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 == before);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
TEST_CASE("ColorGrade JSON round-trip preserves animated wheels and curves", "[effect][colorgrade][json]")
|
2026-04-17 22:47:12 -05:00
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
|
|
|
|
effect.temperature = Keyframe(0.25);
|
|
|
|
|
effect.mix = Keyframe(0.75);
|
2026-04-22 15:59:50 -05:00
|
|
|
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);
|
2026-04-17 22:47:12 -05:00
|
|
|
|
|
|
|
|
ColorGrade copy;
|
|
|
|
|
copy.SetJson(effect.Json());
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
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));
|
2026-04-17 22:47:12 -05:00
|
|
|
CHECK(copy.JsonValue()["wheels"]["global"]["color"].asString() == "#ff8800");
|
2026-04-22 15:59:50 -05:00
|
|
|
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));
|
2026-04-17 22:47:12 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
TEST_CASE("PropertiesJSON exposes animated wheels and curve properties", "[effect][colorgrade][ui]")
|
2026-04-17 22:47:12 -05:00
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
|
|
|
|
std::istringstream props(effect.PropertiesJSON(1));
|
|
|
|
|
Json::CharReaderBuilder rb;
|
|
|
|
|
Json::Value root;
|
|
|
|
|
std::string errs;
|
|
|
|
|
REQUIRE(Json::parseFromStream(rb, props, &root, &errs));
|
|
|
|
|
CHECK(root.isMember("wheels"));
|
|
|
|
|
CHECK(root["wheels"]["type"].asString() == "colorgrade_wheels");
|
|
|
|
|
CHECK(root["wheels"].isMember("wheels"));
|
|
|
|
|
CHECK(root["wheels"]["wheels"]["enabled"].asBool() == true);
|
2026-04-22 15:59:50 -05:00
|
|
|
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);
|
2026-04-17 22:47:12 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
TEST_CASE("Animated curve data brightens matching pixels", "[effect][colorgrade][curve]")
|
2026-04-17 22:47:12 -05:00
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
2026-04-22 15:59:50 -05:00
|
|
|
effect.curve_all = makeBrightCurve();
|
2026-04-17 22:47:12 -05:00
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
TEST_CASE("Animated curve data supports smooth bezier points", "[effect][colorgrade][curve][bezier]")
|
2026-04-17 22:47:12 -05:00
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
2026-04-22 15:59:50 -05:00
|
|
|
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);
|
2026-04-17 22:47:12 -05:00
|
|
|
|
|
|
|
|
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 == before);
|
2026-04-22 15:59:50 -05:00
|
|
|
CHECK(effect.curve_all.JsonValue()["nodes"].size() == 3);
|
2026-04-17 22:47:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("Wheel data shifts tonal balance", "[effect][colorgrade][wheels]")
|
|
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
2026-04-22 15:59:50 -05:00
|
|
|
effect.wheels.shadows.color = Color("#ff6600");
|
|
|
|
|
effect.wheels.shadows.amount = Keyframe(0.8);
|
|
|
|
|
effect.wheels.shadows.luma = Keyframe(0.1);
|
2026-04-17 22:47:12 -05:00
|
|
|
|
|
|
|
|
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 != before);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-22 15:59:50 -05:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-17 22:47:12 -05:00
|
|
|
TEST_CASE("Disabled wheels preserve values but render as identity", "[effect][colorgrade][wheels]")
|
|
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
2026-04-22 15:59:50 -05:00
|
|
|
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);
|
2026-04-17 22:47:12 -05:00
|
|
|
|
|
|
|
|
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 == before);
|
2026-04-22 15:59:50 -05:00
|
|
|
CHECK(effect.wheels.enabled.GetValue(1) == Approx(0.0));
|
|
|
|
|
CHECK(effect.wheels.shadows.amount.GetValue(1) == Approx(0.8));
|
2026-04-17 22:47:12 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("Identity ColorGrade preserves semi-transparent pixels", "[effect][colorgrade][alpha]")
|
|
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
|
|
|
|
auto frame = makeTransparentGradeFrame();
|
|
|
|
|
const QColor before = frame->GetImage()->pixelColor(0, 0);
|
|
|
|
|
auto out = effect.GetFrame(frame, 1);
|
|
|
|
|
const QColor after = out->GetImage()->pixelColor(0, 0);
|
|
|
|
|
|
|
|
|
|
CHECK(after == before);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
TEST_CASE("LUT path and intensity reuse ColorMap behavior", "[effect][colorgrade][lut]")
|
|
|
|
|
{
|
|
|
|
|
ColorGrade effect;
|
|
|
|
|
Json::Value update(Json::objectValue);
|
|
|
|
|
update["lut_path"] = lutPath();
|
|
|
|
|
update["lut_intensity"] = Keyframe(1.0).JsonValue();
|
|
|
|
|
effect.SetJsonValue(update);
|
|
|
|
|
|
|
|
|
|
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 != before);
|
|
|
|
|
}
|