diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 62ce05305..d2e947bc7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -118,6 +118,7 @@ set(EFFECTS_SOURCES effects/CropHelpers.cpp effects/Deinterlace.cpp effects/Displace.cpp + effects/Glow.cpp effects/Hue.cpp effects/LensFlare.cpp effects/AnalogTape.cpp @@ -126,6 +127,7 @@ set(EFFECTS_SOURCES effects/Pixelate.cpp effects/Saturation.cpp effects/Sharpen.cpp + effects/Shadow.cpp effects/Shift.cpp effects/SphericalProjection.cpp effects/Wave.cpp diff --git a/src/EffectInfo.cpp b/src/EffectInfo.cpp index e86f0ef97..17ca3cf7b 100644 --- a/src/EffectInfo.cpp +++ b/src/EffectInfo.cpp @@ -59,6 +59,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) { else if (effect_type == "Displace") return new Displace(); + else if (effect_type == "Glow") + return new Glow(); + else if (effect_type == "Hue") return new Hue(); @@ -80,6 +83,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) { else if (effect_type == "Sharpen") return new Sharpen(); + else if (effect_type == "Shadow") + return new Shadow(); + else if (effect_type == "Shift") return new Shift(); @@ -151,6 +157,7 @@ Json::Value EffectInfo::JsonValue() { root.append(Crop().JsonInfo()); root.append(Deinterlace().JsonInfo()); root.append(Displace().JsonInfo()); + root.append(Glow().JsonInfo()); root.append(Hue().JsonInfo()); root.append(LensFlare().JsonInfo()); root.append(Mask().JsonInfo()); @@ -158,6 +165,7 @@ Json::Value EffectInfo::JsonValue() { root.append(Pixelate().JsonInfo()); root.append(Saturation().JsonInfo()); root.append(Sharpen().JsonInfo()); + root.append(Shadow().JsonInfo()); root.append(Shift().JsonInfo()); root.append(SphericalProjection().JsonInfo()); root.append(Wave().JsonInfo()); diff --git a/src/Effects.h b/src/Effects.h index e3ea2aecd..1b683979b 100644 --- a/src/Effects.h +++ b/src/Effects.h @@ -25,6 +25,7 @@ #include "effects/Crop.h" #include "effects/Deinterlace.h" #include "effects/Displace.h" +#include "effects/Glow.h" #include "effects/Hue.h" #include "effects/LensFlare.h" #include "effects/Mask.h" @@ -32,6 +33,7 @@ #include "effects/Pixelate.h" #include "effects/Saturation.h" #include "effects/Sharpen.h" +#include "effects/Shadow.h" #include "effects/SphericalProjection.h" #include "effects/Shift.h" #include "effects/Wave.h" diff --git a/src/effects/Glow.cpp b/src/effects/Glow.cpp new file mode 100644 index 000000000..c71d75079 --- /dev/null +++ b/src/effects/Glow.cpp @@ -0,0 +1,329 @@ +/** + * @file + * @brief Source file for Glow effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "Glow.h" + +#include "Exceptions.h" + +#include +#include +#include +#include + +using namespace openshot; + +namespace { + inline float glow_clampf(float value, float low, float high) { + if (value < low) + return low; + if (value > high) + return high; + return value; + } + + inline void glow_blur_horizontal(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + const float inv = 1.0f / static_cast((radius * 2) + 1); + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + int ti = y * w; + int li = ti; + int ri = ti + radius; + float first = src[ti]; + float last = src[ti + w - 1]; + float value = static_cast(radius + 1) * first; + for (int j = 0; j < radius; ++j) + value += src[ti + j]; + for (int x = 0; x <= radius; ++x) { + value += src[ri++] - first; + dst[ti++] = value * inv; + } + for (int x = radius + 1; x < w - radius; ++x) { + value += src[ri++] - src[li++]; + dst[ti++] = value * inv; + } + for (int x = w - radius; x < w; ++x) { + value += last - src[li++]; + dst[ti++] = value * inv; + } + } + } + + inline void glow_blur_vertical(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + const float inv = 1.0f / static_cast((radius * 2) + 1); + #pragma omp parallel for schedule(static) + for (int x = 0; x < w; ++x) { + int ti = x; + int li = ti; + int ri = ti + (radius * w); + float first = src[ti]; + float last = src[ti + (w * (h - 1))]; + float value = static_cast(radius + 1) * first; + for (int j = 0; j < radius; ++j) + value += src[ti + (j * w)]; + for (int y = 0; y <= radius; ++y) { + value += src[ri] - first; + dst[ti] = value * inv; + ri += w; + ti += w; + } + for (int y = radius + 1; y < h - radius; ++y) { + value += src[ri] - src[li]; + dst[ti] = value * inv; + li += w; + ri += w; + ti += w; + } + for (int y = h - radius; y < h; ++y) { + value += last - src[li]; + dst[ti] = value * inv; + li += w; + ti += w; + } + } + } + + inline void glow_apply_box_blur(std::vector& mask, int w, int h, int radius, int iterations) { + if (radius <= 0 || iterations <= 0 || w <= 0 || h <= 0) + return; + + std::vector temp(mask.size()); + for (int i = 0; i < iterations; ++i) { + glow_blur_horizontal(mask, temp, w, h, radius); + glow_blur_vertical(temp, mask, w, h, radius); + } + } + + inline void glow_spread_horizontal(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + const int row = y * w; + for (int x = 0; x < w; ++x) { + const int left = std::max(0, x - radius); + const int right = std::min(w - 1, x + radius); + float value = 0.0f; + for (int sample_x = left; sample_x <= right; ++sample_x) + value = std::max(value, src[row + sample_x]); + dst[row + x] = value; + } + } + } + + inline void glow_spread_vertical(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + const int top = std::max(0, y - radius); + const int bottom = std::min(h - 1, y + radius); + for (int x = 0; x < w; ++x) { + float value = 0.0f; + for (int sample_y = top; sample_y <= bottom; ++sample_y) + value = std::max(value, src[(sample_y * w) + x]); + dst[(y * w) + x] = value; + } + } + } + + inline void glow_apply_spread(std::vector& mask, int w, int h, int radius) { + if (radius <= 0 || w <= 0 || h <= 0) + return; + + std::vector temp(mask.size()); + glow_spread_horizontal(mask, temp, w, h, radius); + glow_spread_vertical(temp, mask, w, h, radius); + } +} + +Glow::Glow() + : mode(GLOW_MODE_OUTER), opacity(0.45), blur_radius(20.0), spread(0.10), color("#fff4c2") { + init_effect_details(); +} + +Glow::Glow(Keyframe new_opacity, Keyframe new_blur_radius, Keyframe new_spread, Color new_color) + : mode(GLOW_MODE_OUTER), opacity(new_opacity), blur_radius(new_blur_radius), spread(new_spread), color(new_color) { + init_effect_details(); +} + +void Glow::init_effect_details() +{ + InitEffectInfo(); + info.class_name = "Glow"; + info.name = "Glow"; + info.description = "Add an outer or inner glow based on visible pixels."; + info.has_audio = false; + info.has_video = true; + info.apply_before_clip = false; +} + +std::shared_ptr Glow::GetFrame(std::shared_ptr frame, int64_t frame_number) +{ + auto frame_image = frame->GetImage(); + if (!frame_image || frame_image->isNull()) + return frame; + + const int w = frame_image->width(); + const int h = frame_image->height(); + if (w <= 0 || h <= 0) + return frame; + + QImage source = frame_image->copy(); + const unsigned char* source_pixels = reinterpret_cast(source.constBits()); + + const float opacity_value = glow_clampf(static_cast(opacity.GetValue(frame_number)), 0.0f, 1.0f); + const int blur_value = std::max(0, blur_radius.GetInt(frame_number)); + const float spread_value = glow_clampf(static_cast(spread.GetValue(frame_number)), 0.0f, 1.0f); + const std::vector rgba = color.GetColorRGBA(frame_number); + const float color_r = static_cast(rgba[0]); + const float color_g = static_cast(rgba[1]); + const float color_b = static_cast(rgba[2]); + const float color_a = static_cast(rgba[3]) / 255.0f; + + std::vector alpha_mask(static_cast(w * h)); + #pragma omp parallel for schedule(static) + for (int i = 0; i < (w * h); ++i) { + const float alpha = static_cast(source_pixels[(i * 4) + 3]) / 255.0f; + alpha_mask[i] = alpha; + } + + const int spread_radius = std::max(0, static_cast(std::lround(static_cast(blur_value) * spread_value))); + if (spread_radius > 0) + glow_apply_spread(alpha_mask, w, h, spread_radius); + + std::vector blurred_mask = alpha_mask; + if (blur_value > 0) + glow_apply_box_blur(blurred_mask, w, h, blur_value, 3); + + QImage glow_overlay(w, h, QImage::Format_RGBA8888_Premultiplied); + glow_overlay.fill(Qt::transparent); + unsigned char* glow_pixels = reinterpret_cast(glow_overlay.bits()); + + if (mode == GLOW_MODE_OUTER) { + #pragma omp parallel for schedule(static) + for (int i = 0; i < (w * h); ++i) { + const float overlay_alpha = glow_clampf(blurred_mask[i] * opacity_value * color_a, 0.0f, 1.0f); + const int idx = i * 4; + glow_pixels[idx + 0] = static_cast(color_r * overlay_alpha); + glow_pixels[idx + 1] = static_cast(color_g * overlay_alpha); + glow_pixels[idx + 2] = static_cast(color_b * overlay_alpha); + glow_pixels[idx + 3] = static_cast(255.0f * overlay_alpha); + } + } + else { + std::vector inverse_mask(static_cast(w * h)); + #pragma omp parallel for schedule(static) + for (int i = 0; i < (w * h); ++i) + inverse_mask[i] = 1.0f - alpha_mask[i]; + + if (blur_value > 0) + glow_apply_box_blur(inverse_mask, w, h, blur_value, 3); + + #pragma omp parallel for schedule(static) + for (int i = 0; i < (w * h); ++i) { + const float inner = glow_clampf(inverse_mask[i] * alpha_mask[i] * opacity_value * color_a, 0.0f, 1.0f); + const int idx = i * 4; + glow_pixels[idx + 0] = static_cast(color_r * inner); + glow_pixels[idx + 1] = static_cast(color_g * inner); + glow_pixels[idx + 2] = static_cast(color_b * inner); + glow_pixels[idx + 3] = static_cast(255.0f * inner); + } + } + + QImage result(w, h, QImage::Format_RGBA8888_Premultiplied); + result.fill(Qt::transparent); + { + QPainter painter(&result); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform, true); + if (mode == GLOW_MODE_OUTER) { + painter.drawImage(0, 0, glow_overlay); + painter.drawImage(0, 0, source); + } else { + painter.drawImage(0, 0, source); + painter.drawImage(0, 0, glow_overlay); + } + } + + *frame_image = result; + return frame; +} + +std::string Glow::Json() const { + return JsonValue().toStyledString(); +} + +Json::Value Glow::JsonValue() const { + Json::Value root = EffectBase::JsonValue(); + root["type"] = info.class_name; + root["mode"] = mode; + root["opacity"] = opacity.JsonValue(); + root["blur_radius"] = blur_radius.JsonValue(); + root["spread"] = spread.JsonValue(); + root["color"] = color.JsonValue(); + return root; +} + +void Glow::SetJson(const std::string value) { + try { + const Json::Value root = openshot::stringToJson(value); + SetJsonValue(root); + } + catch (const std::exception& e) { + throw InvalidJSON("JSON is invalid (missing keys or invalid data types)"); + } +} + +void Glow::SetJsonValue(const Json::Value root) { + EffectBase::SetJsonValue(root); + if (!root["mode"].isNull()) + mode = root["mode"].asInt(); + if (!root["opacity"].isNull()) + opacity.SetJsonValue(root["opacity"]); + if (!root["blur_radius"].isNull()) + blur_radius.SetJsonValue(root["blur_radius"]); + if (!root["spread"].isNull()) + spread.SetJsonValue(root["spread"]); + if (!root["color"].isNull()) + color.SetJsonValue(root["color"]); +} + +std::string Glow::PropertiesJSON(int64_t requested_frame) const { + Json::Value root = BasePropertiesJSON(requested_frame); + root["mode"] = add_property_json("Mode", mode, "int", "", NULL, 0, 1, false, requested_frame); + root["mode"]["choices"].append(add_property_choice_json("Outer", GLOW_MODE_OUTER, mode)); + root["mode"]["choices"].append(add_property_choice_json("Inner", GLOW_MODE_INNER, mode)); + root["opacity"] = add_property_json("Opacity", opacity.GetValue(requested_frame), "float", "", &opacity, 0.0, 1.0, false, requested_frame); + root["blur_radius"] = add_property_json("Blur Radius", blur_radius.GetValue(requested_frame), "float", "", &blur_radius, 0.0, 100.0, false, requested_frame); + root["spread"] = add_property_json("Spread", spread.GetValue(requested_frame), "float", "", &spread, 0.0, 1.0, false, requested_frame); + root["color"] = add_property_json("Color", 0.0, "color", "", &color.red, 0, 255, false, requested_frame); + root["color"]["red"] = add_property_json("Red", color.red.GetValue(requested_frame), "float", "", &color.red, 0, 255, false, requested_frame); + root["color"]["green"] = add_property_json("Green", color.green.GetValue(requested_frame), "float", "", &color.green, 0, 255, false, requested_frame); + root["color"]["blue"] = add_property_json("Blue", color.blue.GetValue(requested_frame), "float", "", &color.blue, 0, 255, false, requested_frame); + root["color"]["alpha"] = add_property_json("Alpha", color.alpha.GetValue(requested_frame), "float", "", &color.alpha, 0, 255, false, requested_frame); + return root.toStyledString(); +} diff --git a/src/effects/Glow.h b/src/effects/Glow.h new file mode 100644 index 000000000..604a8402d --- /dev/null +++ b/src/effects/Glow.h @@ -0,0 +1,63 @@ +/** + * @file + * @brief Header file for Glow effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_GLOW_EFFECT_H +#define OPENSHOT_GLOW_EFFECT_H + +#include "../Color.h" +#include "../EffectBase.h" +#include "../Frame.h" +#include "../Json.h" +#include "../KeyFrame.h" + +#include +#include + +namespace openshot +{ + enum GlowMode { + GLOW_MODE_OUTER = 0, + GLOW_MODE_INNER = 1 + }; + + class Glow : public EffectBase + { + private: + void init_effect_details(); + + public: + int mode; ///< Outer or inner glow mode. + Keyframe opacity; ///< Overall glow opacity. + Keyframe blur_radius; ///< Blur radius in pixels. + Keyframe spread; ///< Boosts the source alpha before blur. + Color color; ///< Glow tint color. + + Glow(); + Glow(Keyframe new_opacity, Keyframe new_blur_radius, Keyframe new_spread, Color new_color); + + std::shared_ptr GetFrame(int64_t frame_number) override { + return GetFrame(std::make_shared(), frame_number); + } + + std::shared_ptr GetFrame(std::shared_ptr frame, + int64_t frame_number) override; + + std::string Json() const override; + void SetJson(const std::string value) override; + Json::Value JsonValue() const override; + void SetJsonValue(const Json::Value root) override; + + std::string PropertiesJSON(int64_t requested_frame) const override; + }; +} + +#endif diff --git a/src/effects/Shadow.cpp b/src/effects/Shadow.cpp new file mode 100644 index 000000000..41a563571 --- /dev/null +++ b/src/effects/Shadow.cpp @@ -0,0 +1,344 @@ +/** + * @file + * @brief Source file for Shadow effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "Shadow.h" + +#include "Exceptions.h" + +#include +#include +#include +#include + +using namespace openshot; + +namespace { + inline float shadow_clampf(float value, float low, float high) { + if (value < low) + return low; + if (value > high) + return high; + return value; + } + + inline void shadow_blur_horizontal(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + const float inv = 1.0f / static_cast((radius * 2) + 1); + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + int ti = y * w; + int li = ti; + int ri = ti + radius; + float first = src[ti]; + float last = src[ti + w - 1]; + float value = static_cast(radius + 1) * first; + for (int j = 0; j < radius; ++j) + value += src[ti + j]; + for (int x = 0; x <= radius; ++x) { + value += src[ri++] - first; + dst[ti++] = value * inv; + } + for (int x = radius + 1; x < w - radius; ++x) { + value += src[ri++] - src[li++]; + dst[ti++] = value * inv; + } + for (int x = w - radius; x < w; ++x) { + value += last - src[li++]; + dst[ti++] = value * inv; + } + } + } + + inline void shadow_blur_vertical(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + const float inv = 1.0f / static_cast((radius * 2) + 1); + #pragma omp parallel for schedule(static) + for (int x = 0; x < w; ++x) { + int ti = x; + int li = ti; + int ri = ti + (radius * w); + float first = src[ti]; + float last = src[ti + (w * (h - 1))]; + float value = static_cast(radius + 1) * first; + for (int j = 0; j < radius; ++j) + value += src[ti + (j * w)]; + for (int y = 0; y <= radius; ++y) { + value += src[ri] - first; + dst[ti] = value * inv; + ri += w; + ti += w; + } + for (int y = radius + 1; y < h - radius; ++y) { + value += src[ri] - src[li]; + dst[ti] = value * inv; + li += w; + ri += w; + ti += w; + } + for (int y = h - radius; y < h; ++y) { + value += last - src[li]; + dst[ti] = value * inv; + li += w; + ti += w; + } + } + } + + inline void shadow_apply_box_blur(std::vector& mask, int w, int h, int radius, int iterations) { + if (radius <= 0 || iterations <= 0 || w <= 0 || h <= 0) + return; + + std::vector temp(mask.size()); + for (int i = 0; i < iterations; ++i) { + shadow_blur_horizontal(mask, temp, w, h, radius); + shadow_blur_vertical(temp, mask, w, h, radius); + } + } + + inline void shadow_spread_horizontal(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + const int row = y * w; + for (int x = 0; x < w; ++x) { + const int left = std::max(0, x - radius); + const int right = std::min(w - 1, x + radius); + float value = 0.0f; + for (int sample_x = left; sample_x <= right; ++sample_x) + value = std::max(value, src[row + sample_x]); + dst[row + x] = value; + } + } + } + + inline void shadow_spread_vertical(const std::vector& src, std::vector& dst, int w, int h, int radius) { + if (radius <= 0) { + dst = src; + return; + } + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + const int top = std::max(0, y - radius); + const int bottom = std::min(h - 1, y + radius); + for (int x = 0; x < w; ++x) { + float value = 0.0f; + for (int sample_y = top; sample_y <= bottom; ++sample_y) + value = std::max(value, src[(sample_y * w) + x]); + dst[(y * w) + x] = value; + } + } + } + + inline void shadow_apply_spread(std::vector& mask, int w, int h, int radius) { + if (radius <= 0 || w <= 0 || h <= 0) + return; + + std::vector temp(mask.size()); + shadow_spread_horizontal(mask, temp, w, h, radius); + shadow_spread_vertical(temp, mask, w, h, radius); + } + + inline std::vector shadow_shift_mask(const std::vector& src, int w, int h, + int offset_x, int offset_y) { + std::vector shifted(static_cast(w * h), 0.0f); + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const int src_x = x - offset_x; + const int src_y = y - offset_y; + if (src_x < 0 || src_x >= w || src_y < 0 || src_y >= h) + continue; + + shifted[(y * w) + x] = src[(src_y * w) + src_x]; + } + } + + return shifted; + } + + inline QImage make_shadow_overlay(const std::vector& mask, int w, int h, + const std::vector& rgba, float opacity) { + QImage overlay(w, h, QImage::Format_RGBA8888_Premultiplied); + overlay.fill(Qt::transparent); + unsigned char* pixels = reinterpret_cast(overlay.bits()); + + const float base_r = static_cast(rgba[0]); + const float base_g = static_cast(rgba[1]); + const float base_b = static_cast(rgba[2]); + const float base_a = static_cast(rgba[3]) / 255.0f; + + #pragma omp parallel for schedule(static) + for (int y = 0; y < h; ++y) { + for (int x = 0; x < w; ++x) { + const float alpha = shadow_clampf(mask[(y * w) + x] * opacity * base_a, 0.0f, 1.0f); + if (alpha <= 0.0f) + continue; + + const int idx = ((y * w) + x) * 4; + pixels[idx + 0] = static_cast(base_r * alpha); + pixels[idx + 1] = static_cast(base_g * alpha); + pixels[idx + 2] = static_cast(base_b * alpha); + pixels[idx + 3] = static_cast(255.0f * alpha); + } + } + + return overlay; + } +} + +Shadow::Shadow() + : opacity(0.6), blur_radius(18.0), spread(0.12), distance(10.0), angle(60.0), color("#000000") { + init_effect_details(); +} + +Shadow::Shadow(Keyframe new_opacity, Keyframe new_blur_radius, Keyframe new_spread, + Keyframe new_distance, Keyframe new_angle, Color new_color) + : opacity(new_opacity), blur_radius(new_blur_radius), spread(new_spread), + distance(new_distance), angle(new_angle), color(new_color) { + init_effect_details(); +} + +void Shadow::init_effect_details() +{ + InitEffectInfo(); + info.class_name = "Shadow"; + info.name = "Shadow"; + info.description = "Add a soft drop shadow based on visible pixels."; + info.has_audio = false; + info.has_video = true; + info.apply_before_clip = false; +} + +std::shared_ptr Shadow::GetFrame(std::shared_ptr frame, int64_t frame_number) +{ + auto frame_image = frame->GetImage(); + if (!frame_image || frame_image->isNull()) + return frame; + + const int w = frame_image->width(); + const int h = frame_image->height(); + if (w <= 0 || h <= 0) + return frame; + + QImage source = frame_image->copy(); + const unsigned char* source_pixels = reinterpret_cast(source.constBits()); + + const float opacity_value = shadow_clampf(static_cast(opacity.GetValue(frame_number)), 0.0f, 1.0f); + const int blur_value = std::max(0, blur_radius.GetInt(frame_number)); + const float spread_value = shadow_clampf(static_cast(spread.GetValue(frame_number)), 0.0f, 1.0f); + const float distance_value = static_cast(distance.GetValue(frame_number)); + const float angle_value = static_cast(angle.GetValue(frame_number)); + const std::vector rgba = color.GetColorRGBA(frame_number); + const float radians = angle_value * static_cast(M_PI / 180.0); + const int shadow_x = static_cast(std::lround(std::cos(radians) * distance_value)); + const int shadow_y = static_cast(std::lround(std::sin(radians) * distance_value)); + + std::vector alpha_mask(static_cast(w * h)); + #pragma omp parallel for schedule(static) + for (int i = 0; i < (w * h); ++i) { + const float alpha = static_cast(source_pixels[(i * 4) + 3]) / 255.0f; + alpha_mask[i] = alpha; + } + + const int spread_radius = std::max(0, static_cast(std::lround(static_cast(blur_value) * spread_value))); + if (spread_radius > 0) + shadow_apply_spread(alpha_mask, w, h, spread_radius); + + std::vector shadow_mask = shadow_shift_mask(alpha_mask, w, h, shadow_x, shadow_y); + if (blur_value > 0) + shadow_apply_box_blur(shadow_mask, w, h, blur_value, 3); + + QImage result(w, h, QImage::Format_RGBA8888_Premultiplied); + result.fill(Qt::transparent); + + { + QPainter painter(&result); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform, true); + QImage overlay = make_shadow_overlay(shadow_mask, w, h, rgba, opacity_value); + painter.drawImage(0, 0, overlay); + painter.drawImage(0, 0, source); + } + + *frame_image = result; + return frame; +} + +std::string Shadow::Json() const { + return JsonValue().toStyledString(); +} + +Json::Value Shadow::JsonValue() const { + Json::Value root = EffectBase::JsonValue(); + root["type"] = info.class_name; + root["opacity"] = opacity.JsonValue(); + root["blur_radius"] = blur_radius.JsonValue(); + root["spread"] = spread.JsonValue(); + root["distance"] = distance.JsonValue(); + root["angle"] = angle.JsonValue(); + root["color"] = color.JsonValue(); + return root; +} + +void Shadow::SetJson(const std::string value) { + try { + const Json::Value root = openshot::stringToJson(value); + SetJsonValue(root); + } + catch (const std::exception& e) { + throw InvalidJSON("JSON is invalid (missing keys or invalid data types)"); + } +} + +void Shadow::SetJsonValue(const Json::Value root) { + EffectBase::SetJsonValue(root); + if (!root["opacity"].isNull()) + opacity.SetJsonValue(root["opacity"]); + if (!root["blur_radius"].isNull()) + blur_radius.SetJsonValue(root["blur_radius"]); + if (!root["spread"].isNull()) + spread.SetJsonValue(root["spread"]); + if (!root["distance"].isNull()) + distance.SetJsonValue(root["distance"]); + if (!root["angle"].isNull()) + angle.SetJsonValue(root["angle"]); + if (!root["color"].isNull()) + color.SetJsonValue(root["color"]); +} + +std::string Shadow::PropertiesJSON(int64_t requested_frame) const { + Json::Value root = BasePropertiesJSON(requested_frame); + root["opacity"] = add_property_json("Opacity", opacity.GetValue(requested_frame), "float", "", &opacity, 0.0, 1.0, false, requested_frame); + root["blur_radius"] = add_property_json("Blur Radius", blur_radius.GetValue(requested_frame), "float", "", &blur_radius, 0.0, 100.0, false, requested_frame); + root["spread"] = add_property_json("Spread", spread.GetValue(requested_frame), "float", "", &spread, 0.0, 1.0, false, requested_frame); + root["distance"] = add_property_json("Distance", distance.GetValue(requested_frame), "float", "", &distance, -500.0, 500.0, false, requested_frame); + root["angle"] = add_property_json("Angle", angle.GetValue(requested_frame), "float", "", &angle, -360.0, 360.0, false, requested_frame); + root["color"] = add_property_json("Color", 0.0, "color", "", &color.red, 0, 255, false, requested_frame); + root["color"]["red"] = add_property_json("Red", color.red.GetValue(requested_frame), "float", "", &color.red, 0, 255, false, requested_frame); + root["color"]["green"] = add_property_json("Green", color.green.GetValue(requested_frame), "float", "", &color.green, 0, 255, false, requested_frame); + root["color"]["blue"] = add_property_json("Blue", color.blue.GetValue(requested_frame), "float", "", &color.blue, 0, 255, false, requested_frame); + root["color"]["alpha"] = add_property_json("Alpha", color.alpha.GetValue(requested_frame), "float", "", &color.alpha, 0, 255, false, requested_frame); + return root.toStyledString(); +} diff --git a/src/effects/Shadow.h b/src/effects/Shadow.h new file mode 100644 index 000000000..f9d4ba9ae --- /dev/null +++ b/src/effects/Shadow.h @@ -0,0 +1,60 @@ +/** + * @file + * @brief Header file for Shadow effect class + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_SHADOW_EFFECT_H +#define OPENSHOT_SHADOW_EFFECT_H + +#include "../Color.h" +#include "../EffectBase.h" +#include "../Frame.h" +#include "../Json.h" +#include "../KeyFrame.h" + +#include +#include + +namespace openshot +{ + class Shadow : public EffectBase + { + private: + void init_effect_details(); + + public: + Keyframe opacity; ///< Overall shadow opacity. + Keyframe blur_radius; ///< Blur radius in pixels. + Keyframe spread; ///< Boosts the source alpha before blur. + Keyframe distance; ///< Shadow offset distance in pixels. + Keyframe angle; ///< Shadow angle in degrees. + Color color; ///< Shadow tint color. + + Shadow(); + Shadow(Keyframe new_opacity, Keyframe new_blur_radius, Keyframe new_spread, + Keyframe new_distance, Keyframe new_angle, Color new_color); + + std::shared_ptr GetFrame(int64_t frame_number) override { + return GetFrame(std::make_shared(), frame_number); + } + + std::shared_ptr GetFrame(std::shared_ptr frame, + int64_t frame_number) override; + + std::string Json() const override; + void SetJson(const std::string value) override; + Json::Value JsonValue() const override; + void SetJsonValue(const Json::Value root) override; + + std::string PropertiesJSON(int64_t requested_frame) const override; + }; +} + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 721c29acf..6dde76f07 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,12 +54,14 @@ set(OPENSHOT_TESTS ChromaKey Crop Displace + Glow LensFlare AnalogTape BenchmarkArgs EffectMask Mask Sharpen + Shadow SphericalEffect WaveEffect ) diff --git a/tests/Glow.cpp b/tests/Glow.cpp new file mode 100644 index 000000000..bfcd8f9bd --- /dev/null +++ b/tests/Glow.cpp @@ -0,0 +1,146 @@ +/** + * @file + * @brief Unit tests for Glow effect + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include + +#include +#include + +#include "Frame.h" +#include "effects/Glow.h" +#include "openshot_catch.h" + +using namespace openshot; + +static std::ostream& operator<<(std::ostream& os, QColor const& c) +{ + os << "QColor(" << c.red() << "," << c.green() + << "," << c.blue() << "," << c.alpha() << ")"; + return os; +} + +static std::shared_ptr makeSinglePixelFrame() +{ + QImage img(5, 5, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + img.setPixelColor(2, 2, QColor(255, 255, 255, 255)); + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +static std::shared_ptr makeSolidSquareFrame() +{ + QImage img(5, 5, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + for (int y = 1; y <= 3; ++y) { + for (int x = 1; x <= 3; ++x) + img.setPixelColor(x, y, QColor(255, 255, 255, 255)); + } + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +static std::shared_ptr makeHardSquareFrame() +{ + QImage img(64, 64, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + for (int y = 20; y < 44; ++y) { + for (int x = 20; x < 44; ++x) + img.setPixelColor(x, y, QColor(255, 255, 255, 255)); + } + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +TEST_CASE("Glow outer mode brightens transparent neighbors", "[effect][glow]") +{ + Glow effect; + effect.mode = GLOW_MODE_OUTER; + effect.opacity = Keyframe(1.0); + effect.blur_radius = Keyframe(1.0); + effect.spread = Keyframe(0.0); + effect.color = Color("#ff0000"); + + auto frame = makeSinglePixelFrame(); + const QColor before = frame->GetImage()->pixelColor(1, 2); + + auto out = effect.GetFrame(frame, 1); + const QColor after = out->GetImage()->pixelColor(1, 2); + + CHECK(before.alpha() == 0); + CHECK(after.alpha() > 0); +} + +TEST_CASE("Glow inner mode tints inner edge without affecting outside", "[effect][glow][inner]") +{ + Glow effect; + effect.mode = GLOW_MODE_INNER; + effect.opacity = Keyframe(1.0); + effect.blur_radius = Keyframe(1.0); + effect.spread = Keyframe(0.0); + effect.color = Color("#00ff00"); + + auto frame = makeSolidSquareFrame(); + const QColor edge_before = frame->GetImage()->pixelColor(1, 2); + const QColor outside_before = frame->GetImage()->pixelColor(0, 2); + + auto out = effect.GetFrame(frame, 1); + const QColor edge_after = out->GetImage()->pixelColor(1, 2); + const QColor outside_after = out->GetImage()->pixelColor(0, 2); + + CHECK(edge_after != edge_before); + CHECK(outside_before.alpha() == 0); + CHECK(outside_after.alpha() == 0); +} + +TEST_CASE("Glow json round-trip preserves mode and color", "[effect][glow][json]") +{ + Glow effect; + effect.mode = GLOW_MODE_INNER; + effect.opacity = Keyframe(0.5); + effect.blur_radius = Keyframe(9.0); + effect.spread = Keyframe(0.25); + effect.color = Color("#204060"); + + const Json::Value json = effect.JsonValue(); + CHECK(json["type"].asString() == "Glow"); + + Glow copy; + copy.SetJsonValue(json); + const Json::Value copy_json = copy.JsonValue(); + + CHECK(copy_json["mode"].asInt() == GLOW_MODE_INNER); + CHECK(copy_json["opacity"]["Points"][0]["co"]["Y"].asDouble() == Approx(0.5)); + CHECK(copy_json["blur_radius"]["Points"][0]["co"]["Y"].asDouble() == Approx(9.0)); + CHECK(copy_json["spread"]["Points"][0]["co"]["Y"].asDouble() == Approx(0.25)); + CHECK(copy_json["color"]["red"]["Points"][0]["co"]["Y"].asDouble() == Approx(32.0)); + CHECK(copy_json["color"]["green"]["Points"][0]["co"]["Y"].asDouble() == Approx(64.0)); + CHECK(copy_json["color"]["blue"]["Points"][0]["co"]["Y"].asDouble() == Approx(96.0)); +} + +TEST_CASE("Glow spread expands hard-edged shapes before blur", "[effect][glow][spread]") +{ + Glow low; + low.mode = GLOW_MODE_OUTER; + low.opacity = Keyframe(1.0); + low.blur_radius = Keyframe(8.0); + low.spread = Keyframe(0.0); + low.color = Color("#ff0000"); + + Glow high = low; + high.spread = Keyframe(1.0); + + auto low_frame = low.GetFrame(makeHardSquareFrame(), 1); + auto high_frame = high.GetFrame(makeHardSquareFrame(), 1); + + CHECK(high_frame->GetImage()->pixelColor(16, 32).alpha() > low_frame->GetImage()->pixelColor(16, 32).alpha()); +} diff --git a/tests/Shadow.cpp b/tests/Shadow.cpp new file mode 100644 index 000000000..fb72c358c --- /dev/null +++ b/tests/Shadow.cpp @@ -0,0 +1,144 @@ +/** + * @file + * @brief Unit tests for Shadow effect + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include + +#include +#include + +#include "Frame.h" +#include "effects/Shadow.h" +#include "openshot_catch.h" + +using namespace openshot; + +static std::ostream& operator<<(std::ostream& os, QColor const& c) +{ + os << "QColor(" << c.red() << "," << c.green() + << "," << c.blue() << "," << c.alpha() << ")"; + return os; +} + +static std::shared_ptr makeSinglePixelFrame() +{ + QImage img(5, 5, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + img.setPixelColor(2, 2, QColor(255, 255, 255, 255)); + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +static std::shared_ptr makeTopEdgeFrame() +{ + QImage img(64, 64, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + for (int y = 0; y < 24; ++y) { + for (int x = 20; x < 44; ++x) + img.setPixelColor(x, y, QColor(255, 255, 255, 255)); + } + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +static std::shared_ptr makeHardSquareFrame() +{ + QImage img(64, 64, QImage::Format_ARGB32); + img.fill(QColor(0, 0, 0, 0)); + for (int y = 20; y < 44; ++y) { + for (int x = 20; x < 44; ++x) + img.setPixelColor(x, y, QColor(255, 255, 255, 255)); + } + auto frame = std::make_shared(); + *frame->GetImage() = img; + return frame; +} + +TEST_CASE("Shadow offsets visible pixels into surrounding area", "[effect][shadow]") +{ + Shadow effect; + effect.opacity = Keyframe(1.0); + effect.blur_radius = Keyframe(0.0); + effect.spread = Keyframe(0.0); + effect.distance = Keyframe(1.0); + effect.angle = Keyframe(0.0); + effect.color = Color("#000000"); + + auto frame = makeSinglePixelFrame(); + const QColor before = frame->GetImage()->pixelColor(3, 2); + + auto out = effect.GetFrame(frame, 1); + const QColor after = out->GetImage()->pixelColor(3, 2); + + CHECK(before.alpha() == 0); + CHECK(after.alpha() > 0); +} + +TEST_CASE("Shadow blur can still reach the canvas edge after offset", "[effect][shadow][edge]") +{ + Shadow effect; + effect.opacity = Keyframe(1.0); + effect.blur_radius = Keyframe(12.0); + effect.spread = Keyframe(0.0); + effect.distance = Keyframe(10.0); + effect.angle = Keyframe(90.0); + effect.color = Color("#000000"); + + auto frame = makeTopEdgeFrame(); + auto out = effect.GetFrame(frame, 1); + + CHECK(out->GetImage()->pixelColor(32, 0).alpha() > 0); +} + +TEST_CASE("Shadow spread expands hard-edged shapes before blur", "[effect][shadow][spread]") +{ + Shadow low; + low.opacity = Keyframe(1.0); + low.blur_radius = Keyframe(8.0); + low.spread = Keyframe(0.0); + low.distance = Keyframe(0.0); + low.angle = Keyframe(0.0); + low.color = Color("#000000"); + + Shadow high = low; + high.spread = Keyframe(1.0); + + auto low_frame = low.GetFrame(makeHardSquareFrame(), 1); + auto high_frame = high.GetFrame(makeHardSquareFrame(), 1); + + CHECK(high_frame->GetImage()->pixelColor(16, 32).alpha() > low_frame->GetImage()->pixelColor(16, 32).alpha()); +} + +TEST_CASE("Shadow json round-trip preserves key properties", "[effect][shadow][json]") +{ + Shadow effect; + effect.opacity = Keyframe(0.42); + effect.blur_radius = Keyframe(7.0); + effect.spread = Keyframe(0.33); + effect.distance = Keyframe(11.0); + effect.angle = Keyframe(60.0); + effect.color = Color("#102030"); + + const Json::Value json = effect.JsonValue(); + CHECK(json["type"].asString() == "Shadow"); + + Shadow copy; + copy.SetJsonValue(json); + const Json::Value copy_json = copy.JsonValue(); + + CHECK(copy_json["opacity"]["Points"][0]["co"]["Y"].asDouble() == Approx(0.42)); + CHECK(copy_json["blur_radius"]["Points"][0]["co"]["Y"].asDouble() == Approx(7.0)); + CHECK(copy_json["spread"]["Points"][0]["co"]["Y"].asDouble() == Approx(0.33)); + CHECK(copy_json["distance"]["Points"][0]["co"]["Y"].asDouble() == Approx(11.0)); + CHECK(copy_json["angle"]["Points"][0]["co"]["Y"].asDouble() == Approx(60.0)); + CHECK(copy_json["color"]["red"]["Points"][0]["co"]["Y"].asDouble() == Approx(16.0)); + CHECK(copy_json["color"]["green"]["Points"][0]["co"]["Y"].asDouble() == Approx(32.0)); + CHECK(copy_json["color"]["blue"]["Points"][0]["co"]["Y"].asDouble() == Approx(48.0)); +}