diff --git a/src/effects/Glow.cpp b/src/effects/Glow.cpp index b9f551d3..c71d7507 100644 --- a/src/effects/Glow.cpp +++ b/src/effects/Glow.cpp @@ -111,6 +111,54 @@ namespace { 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() @@ -131,6 +179,7 @@ void Glow::init_effect_details() 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) @@ -157,13 +206,16 @@ std::shared_ptr Glow::GetFrame(std::shared_ptr const float color_a = static_cast(rgba[3]) / 255.0f; std::vector alpha_mask(static_cast(w * h)); - const float spread_gamma = 1.0f - (spread_value * 0.85f); #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 <= 0.0f) ? 0.0f : std::pow(alpha, std::max(0.05f, spread_gamma)); + 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); diff --git a/src/effects/Shadow.cpp b/src/effects/Shadow.cpp index 76f6755b..41a56357 100644 --- a/src/effects/Shadow.cpp +++ b/src/effects/Shadow.cpp @@ -112,8 +112,74 @@ namespace { } } + 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, - int offset_x, int offset_y, const std::vector& rgba, float opacity) { QImage overlay(w, h, QImage::Format_RGBA8888_Premultiplied); overlay.fill(Qt::transparent); @@ -127,12 +193,7 @@ namespace { #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; - - const float alpha = shadow_clampf(mask[(src_y * w) + src_x] * opacity * base_a, 0.0f, 1.0f); + const float alpha = shadow_clampf(mask[(y * w) + x] * opacity * base_a, 0.0f, 1.0f); if (alpha <= 0.0f) continue; @@ -168,6 +229,7 @@ void Shadow::init_effect_details() 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) @@ -190,29 +252,32 @@ std::shared_ptr Shadow::GetFrame(std::shared_ptr(distance.GetValue(frame_number)); const float angle_value = static_cast(angle.GetValue(frame_number)); const std::vector rgba = color.GetColorRGBA(frame_number); - - std::vector alpha_mask(static_cast(w * h)); - const float spread_gamma = 1.0f - (spread_value * 0.85f); - #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 <= 0.0f) ? 0.0f : std::pow(alpha, std::max(0.05f, spread_gamma)); - } - - if (blur_value > 0) - shadow_apply_box_blur(alpha_mask, w, h, blur_value, 3); - 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(alpha_mask, w, h, shadow_x, shadow_y, rgba, opacity_value); + QImage overlay = make_shadow_overlay(shadow_mask, w, h, rgba, opacity_value); painter.drawImage(0, 0, overlay); painter.drawImage(0, 0, source); } diff --git a/tests/Glow.cpp b/tests/Glow.cpp index 04a03fbe..bfcd8f9b 100644 --- a/tests/Glow.cpp +++ b/tests/Glow.cpp @@ -48,6 +48,19 @@ static std::shared_ptr makeSolidSquareFrame() 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; @@ -113,3 +126,21 @@ TEST_CASE("Glow json round-trip preserves mode and color", "[effect][glow][json] 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 index 200754e1..fb72c358 100644 --- a/tests/Shadow.cpp +++ b/tests/Shadow.cpp @@ -35,6 +35,32 @@ static std::shared_ptr makeSinglePixelFrame() 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; @@ -55,6 +81,41 @@ TEST_CASE("Shadow offsets visible pixels into surrounding area", "[effect][shado 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;