/** * @file * @brief Unit tests for Mask effect behavior and reader compatibility */ // Copyright (c) 2008-2026 OpenShot Studios, LLC // // SPDX-License-Identifier: LGPL-3.0-or-later #include #include #include #include #include #include #include #include #include #include "Frame.h" #include "effects/Mask.h" #include "QtImageReader.h" #include "openshot_catch.h" using namespace openshot; static std::string temp_png_path(const std::string& base) { std::stringstream path; path << QDir::tempPath().toStdString() << "/libopenshot_" << base << "_" << getpid() << "_" << rand() << ".png"; return path.str(); } static std::string create_mask_png(const std::vector& gray_values) { const std::string path = temp_png_path("mask_effect"); QImage mask(static_cast(gray_values.size()), 1, QImage::Format_RGBA8888_Premultiplied); for (size_t i = 0; i < gray_values.size(); ++i) { const int gray = gray_values[i]; mask.setPixelColor(static_cast(i), 0, QColor(gray, gray, gray, 255)); } REQUIRE(mask.save(QString::fromStdString(path))); return path; } TEST_CASE("Mask applies alpha from reader source", "[effect][mask_effect]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(255, 0, 0, 255)); image->setPixelColor(1, 0, QColor(255, 0, 0, 255)); const std::string mask_path = create_mask_png({255, 0}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.brightness = Keyframe(0.0); mask.contrast = Keyframe(0.0); auto out = mask.GetFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 0); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 255); } TEST_CASE("Mask invert flips reader mask alpha mapping", "[effect][mask_effect][invert]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(255, 0, 0, 255)); image->setPixelColor(1, 0, QColor(255, 0, 0, 255)); const std::string mask_path = create_mask_png({255, 0}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.mask_invert = true; mask.brightness = Keyframe(0.0); mask.contrast = Keyframe(0.0); auto out = mask.GetFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 255); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 0); } TEST_CASE("Mask replace_image emits grayscale values", "[effect][mask_effect][replace]") { auto frame = std::make_shared(1, 2, 1, "#000000"); frame->GetImage()->fill(QColor(10, 20, 30, 255)); const std::string mask_path = create_mask_png({255, 0}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.replace_image = true; mask.brightness = Keyframe(0.0); mask.contrast = Keyframe(0.0); auto out = mask.GetFrame(frame, 1); auto px0 = out->GetImage()->pixelColor(0, 0); auto px1 = out->GetImage()->pixelColor(1, 0); CHECK(px0.red() == px0.green()); CHECK(px0.green() == px0.blue()); CHECK(px1.red() == px1.green()); CHECK(px1.green() == px1.blue()); CHECK(px0.alpha() == px0.red()); CHECK(px1.alpha() == px1.red()); } TEST_CASE("Mask accepts legacy reader json field", "[effect][mask_effect][json]") { const std::string mask_path = create_mask_png({128}); QtImageReader reader(mask_path); Json::Value root; root["reader"] = reader.JsonValue(); root["brightness"] = Keyframe(0.0).JsonValue(); root["contrast"] = Keyframe(0.0).JsonValue(); Mask mask; mask.SetJsonValue(root); REQUIRE(mask.Reader() != nullptr); CHECK(mask.JsonValue().isMember("mask_reader")); CHECK(mask.JsonValue()["mask_reader"]["type"].asString() == "QtImageReader"); } TEST_CASE("Mask ProcessFrame brightness 1.0 fully clears output", "[effect][mask_effect][process][brightness]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(255, 10, 10, 255)); image->setPixelColor(1, 0, QColor(255, 10, 10, 255)); const std::string mask_path = create_mask_png({255, 255}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.brightness = Keyframe(1.0); mask.contrast = Keyframe(0.0); auto out = mask.ProcessFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 0); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 0); } TEST_CASE("Mask ProcessFrame honors invert mask property", "[effect][mask_effect][process][invert]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(80, 40, 20, 255)); image->setPixelColor(1, 0, QColor(80, 40, 20, 255)); const std::string mask_path = create_mask_png({255, 0}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.mask_invert = true; mask.brightness = Keyframe(0.0); mask.contrast = Keyframe(0.0); auto out = mask.ProcessFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 255); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 0); } TEST_CASE("Mask ProcessFrame brightness -1.0 keeps output opaque", "[effect][mask_effect][process][brightness]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(20, 200, 20, 255)); image->setPixelColor(1, 0, QColor(20, 200, 20, 255)); const std::string mask_path = create_mask_png({0, 0}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.brightness = Keyframe(-1.0); mask.contrast = Keyframe(0.0); auto out = mask.ProcessFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 255); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 255); } TEST_CASE("Mask ProcessFrame brightness 1.0 ignores gray mask and still clears", "[effect][mask_effect][process][brightness]") { auto frame = std::make_shared(1, 2, 1, "#000000"); auto image = frame->GetImage(); image->setPixelColor(0, 0, QColor(180, 80, 30, 255)); image->setPixelColor(1, 0, QColor(180, 80, 30, 255)); const std::string mask_path = create_mask_png({128, 128}); Mask mask; mask.Reader(new QtImageReader(mask_path)); mask.brightness = Keyframe(1.0); mask.contrast = Keyframe(0.0); auto out = mask.ProcessFrame(frame, 1); CHECK(out->GetImage()->pixelColor(0, 0).alpha() == 0); CHECK(out->GetImage()->pixelColor(1, 0).alpha() == 0); }