From 8ede4d777e91b996c9f819d4894b4b53e9c3cb5a Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 24 Apr 2026 23:06:55 -0500 Subject: [PATCH 1/3] Add Film Grain effect with deterministic, keyframeable controls, profile-scaled grain rendering, bindings, registration, and unit tests. Also update Analog Tape artifact scaling so preview and export sizes stay visually consistent. --- bindings/java/openshot.i | 1 + bindings/python/openshot.i | 1 + bindings/ruby/openshot.i | 1 + src/CMakeLists.txt | 1 + src/EffectInfo.cpp | 4 + src/Effects.h | 1 + src/effects/AnalogTape.cpp | 38 ++-- src/effects/FilmGrain.cpp | 363 +++++++++++++++++++++++++++++++++++++ src/effects/FilmGrain.h | 68 +++++++ tests/CMakeLists.txt | 1 + tests/FilmGrain.cpp | 264 +++++++++++++++++++++++++++ 11 files changed, 730 insertions(+), 13 deletions(-) create mode 100644 src/effects/FilmGrain.cpp create mode 100644 src/effects/FilmGrain.h create mode 100644 tests/FilmGrain.cpp diff --git a/bindings/java/openshot.i b/bindings/java/openshot.i index 0e41ccf19..6cd4260c0 100644 --- a/bindings/java/openshot.i +++ b/bindings/java/openshot.i @@ -246,6 +246,7 @@ typedef struct OpenShotByteBuffer { %include "effects/ColorShift.h" %include "effects/Crop.h" %include "effects/Deinterlace.h" +%include "effects/FilmGrain.h" %include "effects/Hue.h" %include "effects/LensFlare.h" %include "effects/Mask.h" diff --git a/bindings/python/openshot.i b/bindings/python/openshot.i index 736abc67b..6b1c5bef6 100644 --- a/bindings/python/openshot.i +++ b/bindings/python/openshot.i @@ -534,6 +534,7 @@ static int openshot_swig_is_qwidget(PyObject *obj) { %include "effects/ColorShift.h" %include "effects/Crop.h" %include "effects/Deinterlace.h" +%include "effects/FilmGrain.h" %include "effects/Hue.h" %include "effects/LensFlare.h" %include "effects/Mask.h" diff --git a/bindings/ruby/openshot.i b/bindings/ruby/openshot.i index 6a6ebf02b..e2235ef78 100644 --- a/bindings/ruby/openshot.i +++ b/bindings/ruby/openshot.i @@ -273,6 +273,7 @@ typedef struct OpenShotByteBuffer { %include "effects/ColorShift.h" %include "effects/Crop.h" %include "effects/Deinterlace.h" +%include "effects/FilmGrain.h" %include "effects/Hue.h" %include "effects/LensFlare.h" %include "effects/Mask.h" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2827823c9..e720e2fcf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -121,6 +121,7 @@ set(EFFECTS_SOURCES effects/CropHelpers.cpp effects/Deinterlace.cpp effects/Displace.cpp + effects/FilmGrain.cpp effects/Hue.cpp effects/LensFlare.cpp effects/AnalogTape.cpp diff --git a/src/EffectInfo.cpp b/src/EffectInfo.cpp index a726bb98d..7da69ead3 100644 --- a/src/EffectInfo.cpp +++ b/src/EffectInfo.cpp @@ -62,6 +62,9 @@ EffectBase* EffectInfo::CreateEffect(std::string effect_type) { else if (effect_type == "Displace") return new Displace(); + else if (effect_type == "FilmGrain") + return new FilmGrain(); + else if (effect_type == "Hue") return new Hue(); @@ -155,6 +158,7 @@ Json::Value EffectInfo::JsonValue() { root.append(Crop().JsonInfo()); root.append(Deinterlace().JsonInfo()); root.append(Displace().JsonInfo()); + root.append(FilmGrain().JsonInfo()); root.append(Hue().JsonInfo()); root.append(LensFlare().JsonInfo()); root.append(Mask().JsonInfo()); diff --git a/src/Effects.h b/src/Effects.h index 3752f04eb..0885a83c0 100644 --- a/src/Effects.h +++ b/src/Effects.h @@ -26,6 +26,7 @@ #include "effects/Crop.h" #include "effects/Deinterlace.h" #include "effects/Displace.h" +#include "effects/FilmGrain.h" #include "effects/Hue.h" #include "effects/LensFlare.h" #include "effects/Mask.h" diff --git a/src/effects/AnalogTape.cpp b/src/effects/AnalogTape.cpp index 5ccedf867..db09b981c 100644 --- a/src/effects/AnalogTape.cpp +++ b/src/effects/AnalogTape.cpp @@ -118,6 +118,15 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, fps = timeline->info.fps; else if (clip && clip->Reader()) fps = clip->Reader()->info.fps; + int reference_w = w; + int reference_h = h; + if (timeline && timeline->info.width > 0 && timeline->info.height > 0) { + reference_w = timeline->info.width; + reference_h = timeline->info.height; + } + const float reference_scale_x = w > 0 ? static_cast(reference_w) / static_cast(w) : 1.0f; + const float reference_scale_y = h > 0 ? static_cast(reference_h) / static_cast(h) : 1.0f; + const float inverse_scale_x = reference_scale_x > 0.0f ? 1.0f / reference_scale_x : 1.0f; double fps_d = fps.ToDouble(); double t = fps_d > 0 ? frame_number / fps_d : frame_number; @@ -128,7 +137,7 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, const float k_stripe = stripe.GetValue(frame_number); const float k_bands = staticBands.GetValue(frame_number); - int r_y = std::round(lerp(0.0f, 2.0f, k_soft)); + int r_y = std::round(lerp(0.0f, 2.0f, k_soft) * inverse_scale_x); if (k_noise > 0.6f) r_y = std::min(r_y, 1); if (r_y > 0) { @@ -140,8 +149,8 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, Y.swap(tmpY); } - float shift = lerp(0.0f, 2.5f, k_bleed); - int r_c = std::round(lerp(0.0f, 3.0f, k_bleed)); + float shift = lerp(0.0f, 2.5f, k_bleed) * inverse_scale_x; + int r_c = std::round(lerp(0.0f, 3.0f, k_bleed) * inverse_scale_x); float sat = 1.0f - 0.30f * k_bleed; float shift_h = shift * 0.5f; #ifdef _OPENMP @@ -239,6 +248,7 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, #pragma omp parallel for #endif for (int y = 0; y < h; ++y) { + const int y_ref = static_cast(std::round(static_cast(y) * reference_scale_y)); float bandF = 0.0f; if (Hfixed > 0.0f && y >= h - Hfixed) bandF = (y - (h - Hfixed)) / std::max(1.0f, Hfixed); @@ -265,14 +275,14 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, } } - float rowBias = row_density(SEED, frame_number, y); + float rowBias = row_density(SEED, frame_number, y_ref); float p = baseP * (0.25f + 1.5f * rowBias); p *= (1.0f + 1.5f * bandF + 2.0f * burstF); float hum = 0.008f * k_noise * - std::sin(2 * PI * (y * (6.0f / h) + 0.08f * t)); - uint32_t s0 = SEED ^ 0x9e37u * kf ^ 0x85ebu * y; - uint32_t s1 = SEED ^ 0x9e37u * (kf + 1) ^ 0x85ebu * y ^ 0x1234567u; + std::sin(2 * PI * (y_ref * (6.0f / reference_h) + 0.08f * t)); + uint32_t s0 = SEED ^ 0x9e37u * kf ^ 0x85ebu * y_ref; + uint32_t s1 = SEED ^ 0x9e37u * (kf + 1) ^ 0x85ebu * y_ref ^ 0x1234567u; auto step = [](uint32_t &s) { s ^= s << 13; s ^= s >> 17; @@ -282,12 +292,13 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, float lift = Gfixed * bandF + Gburst * burstF; float rowSigma = sigmaY * (1 + (Nfixed - 1) * bandF + (Nburst - 1) * burstF); - float k = 0.15f + 0.35f * hash01(SEED, uint32_t(frame_number), y, 777); + float k = 0.15f + 0.35f * hash01(SEED, uint32_t(frame_number), y_ref, 777); float sL = 0.0f, sR = 0.0f; for (int x = 0; x < w; ++x) { - if (hash01(SEED, uint32_t(frame_number), y, x) < p) + const int x_ref = static_cast(std::round(static_cast(x) * reference_scale_x)); + if (hash01(SEED, uint32_t(frame_number), y_ref, x_ref) < p) sL = 1.0f; - if (hash01(SEED, uint32_t(frame_number), y, w - 1 - x) < p * 0.7f) + if (hash01(SEED, uint32_t(frame_number), y_ref, reference_w - 1 - x_ref) < p * 0.7f) sR = 1.0f; float n = ((step(s0) & 0xFFFFFF) / 16777215.0f) * (1 - a) + ((step(s1) & 0xFFFFFF) / 16777215.0f) * a; @@ -303,13 +314,14 @@ std::shared_ptr AnalogTape::GetFrame(std::shared_ptr frame, } } - float A = lerp(0.0f, 3.0f, k_track); // pixels + float A = lerp(0.0f, 3.0f, k_track) * inverse_scale_x; // pixels float f = lerp(0.25f, 1.2f, k_track); // Hz float Hsk = lerp(0.0f, 0.10f * h, k_track); // pixels - float S = lerp(0.0f, 5.0f, k_track); // pixels + float S = lerp(0.0f, 5.0f, k_track) * inverse_scale_x; // pixels float phase = 2 * PI * (f * t) + 0.7f * (SEED * 0.001f); for (int y = 0; y < h; ++y) { - float base = A * std::sin(2 * PI * 0.0035f * y + phase); + const float y_ref = static_cast(y) * reference_scale_y; + float base = A * std::sin(2 * PI * 0.0035f * y_ref + phase); float skew = (y >= h - Hsk) ? S * ((y - (h - Hsk)) / std::max(1.0f, Hsk)) : 0.0f; diff --git a/src/effects/FilmGrain.cpp b/src/effects/FilmGrain.cpp new file mode 100644 index 000000000..e0fad3d06 --- /dev/null +++ b/src/effects/FilmGrain.cpp @@ -0,0 +1,363 @@ +/** + * @file + * @brief Source file for FilmGrain effect + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include "FilmGrain.h" +#include "Clip.h" +#include "Exceptions.h" +#include "Timeline.h" + +#include +#include +#include +#include + +using namespace openshot; + +namespace { +constexpr float kInv255 = 1.0f / 255.0f; + +static float clamp01(float value) { + return std::max(0.0f, std::min(1.0f, value)); +} + +static int clampByte(float value) { + if (value <= 0.0f) + return 0; + if (value >= 255.0f) + return 255; + return static_cast(std::round(value)); +} + +static float lerp(float a, float b, float t) { + return a + ((b - a) * t); +} + +static uint32_t mix32(uint32_t value) { + value ^= value >> 16; + value *= 0x7feb352du; + value ^= value >> 15; + value *= 0x846ca68bu; + value ^= value >> 16; + return value; +} + +static uint32_t hash_string(const std::string& value) { + uint32_t h = 2166136261u; + for (unsigned char c : value) { + h ^= c; + h *= 16777619u; + } + return h; +} + +static uint32_t hash_coords(uint32_t seed, int x, int y, int t, uint32_t salt) { + uint32_t h = seed ^ salt; + h ^= static_cast(x) * 0x9e3779b9u; + h ^= static_cast(y) * 0x85ebca6bu; + h ^= static_cast(t) * 0xc2b2ae35u; + return mix32(h); +} + +static float hash_signed(uint32_t seed, int x, int y, int t, uint32_t salt) { + return (hash_coords(seed, x, y, t, salt) / 4294967295.0f) * 2.0f - 1.0f; +} + +static float value_noise(uint32_t seed, float x, float y, int time_bucket, uint32_t salt) { + const int x0 = static_cast(std::floor(x)); + const int y0 = static_cast(std::floor(y)); + const int x1 = x0 + 1; + const int y1 = y0 + 1; + const float tx = x - static_cast(x0); + const float ty = y - static_cast(y0); + const float sx = tx * tx * (3.0f - 2.0f * tx); + const float sy = ty * ty * (3.0f - 2.0f * ty); + + const float n00 = hash_signed(seed, x0, y0, time_bucket, salt); + const float n10 = hash_signed(seed, x1, y0, time_bucket, salt); + const float n01 = hash_signed(seed, x0, y1, time_bucket, salt); + const float n11 = hash_signed(seed, x1, y1, time_bucket, salt); + const float nx0 = lerp(n00, n10, sx); + const float nx1 = lerp(n01, n11, sx); + return lerp(nx0, nx1, sy); +} + +static float tonal_weight(float luma, float shadow_strength, float midtone_strength, float highlight_strength) { + const float shadow = (1.0f - luma) * (1.0f - luma); + const float highlight = luma * luma; + const float centered = std::abs(luma - 0.5f) * 2.0f; + const float midtone = clamp01(1.0f - centered * centered); + return (shadow * shadow_strength) + (midtone * midtone_strength) + (highlight * highlight_strength); +} +} + +FilmGrain::FilmGrain() + : amount(0.25), + size(0.20), + softness(0.25), + clump(0.20), + shadows(0.80), + midtones(1.00), + highlights(0.55), + color_amount(0.20), + color_variation(0.35), + evolution(0.65), + coherence(0.55), + seed(1) +{ + init_effect_details(); +} + +void FilmGrain::init_effect_details() { + InitEffectInfo(); + info.class_name = "FilmGrain"; + info.name = "Film Grain"; + info.description = "Film-inspired grain texture with tonal, color, and temporal controls."; + info.has_audio = false; + info.has_video = true; +} + +std::shared_ptr FilmGrain::GetFrame(std::shared_ptr frame, int64_t frame_number) { + std::shared_ptr frame_image = frame->GetImage(); + if (!frame_image) + return frame; + + const float amount_value = clamp01(static_cast(amount.GetValue(frame_number))); + if (amount_value <= 0.00001f) + return frame; + + const float size_value = clamp01(static_cast(size.GetValue(frame_number))); + const float softness_value = clamp01(static_cast(softness.GetValue(frame_number))); + const float clump_value = clamp01(static_cast(clump.GetValue(frame_number))); + const float shadows_value = clamp01(static_cast(shadows.GetValue(frame_number))); + const float midtones_value = clamp01(static_cast(midtones.GetValue(frame_number))); + const float highlights_value = clamp01(static_cast(highlights.GetValue(frame_number))); + const float color_amount_value = clamp01(static_cast(color_amount.GetValue(frame_number))); + const float color_variation_value = clamp01(static_cast(color_variation.GetValue(frame_number))); + const float evolution_value = clamp01(static_cast(evolution.GetValue(frame_number))); + const float coherence_value = clamp01(static_cast(coherence.GetValue(frame_number))); + + const float cell_size = lerp(1.0f, 9.0f, size_value); + const float fine_frequency = 1.0f / cell_size; + const float soft_frequency = fine_frequency * lerp(0.45f, 0.12f, softness_value); + const float clump_frequency = fine_frequency * lerp(0.35f, 0.08f, clump_value); + const float temporal_rate = lerp(0.0f, 1.0f, evolution_value) * lerp(1.0f, 8.0f, 1.0f - coherence_value); + const float temporal_position = static_cast(frame_number) * temporal_rate; + const int time0 = static_cast(std::floor(temporal_position)); + const int time1 = time0 + 1; + const float temporal_mix = temporal_rate <= 0.00001f ? 0.0f : clamp01(temporal_position - static_cast(time0)); + const float smooth_temporal_mix = temporal_mix * temporal_mix * (3.0f - 2.0f * temporal_mix); + const uint32_t base_seed = static_cast(seed) ^ mix32(hash_string(Id())); + const float intensity_scale = amount_value * 0.18f; + const bool use_temporal_blend = temporal_rate > 0.00001f && smooth_temporal_mix > 0.00001f; + const bool use_softness = softness_value > 0.00001f; + const bool use_clump = clump_value > 0.00001f; + const bool use_color = color_amount_value > 0.00001f; + const bool use_color_variation = use_color && color_variation_value > 0.00001f; + + static const std::array inv_alpha = [] { + std::array lut{}; + lut[0] = 0.0f; + for (int i = 1; i < 256; ++i) + lut[i] = 255.0f / static_cast(i); + return lut; + }(); + + unsigned char* pixels = reinterpret_cast(frame_image->bits()); + const int width = frame_image->width(); + const int height = frame_image->height(); + const int stride = frame_image->bytesPerLine(); + const int pixel_count = width * height; + int reference_width = width; + int reference_height = height; + + Clip* clip = static_cast(ParentClip()); + Timeline* timeline = nullptr; + if (clip && clip->ParentTimeline()) + timeline = static_cast(clip->ParentTimeline()); + else if (ParentTimeline()) + timeline = static_cast(ParentTimeline()); + if (timeline && timeline->info.width > 0 && timeline->info.height > 0) { + reference_width = timeline->info.width; + reference_height = timeline->info.height; + } + const float reference_scale_x = width > 0 ? static_cast(reference_width) / static_cast(width) : 1.0f; + const float reference_scale_y = height > 0 ? static_cast(reference_height) / static_cast(height) : 1.0f; + + #pragma omp parallel for if(pixel_count >= 16384) schedule(static) + for (int y = 0; y < height; ++y) { + unsigned char* row = pixels + (y * stride); + for (int x = 0; x < width; ++x) { + const int idx = x * 4; + const int A = row[idx + 3]; + if (A <= 0) + continue; + + float R = 0.0f; + float G = 0.0f; + float B = 0.0f; + const float alpha_percent = static_cast(A) * kInv255; + if (A == 255) { + R = row[idx + 0] * kInv255; + G = row[idx + 1] * kInv255; + B = row[idx + 2] * kInv255; + } else { + const float inv_alpha_percent = inv_alpha[A]; + R = (row[idx + 0] * inv_alpha_percent) * kInv255; + G = (row[idx + 1] * inv_alpha_percent) * kInv255; + B = (row[idx + 2] * inv_alpha_percent) * kInv255; + } + + const float luma = (0.299f * R) + (0.587f * G) + (0.114f * B); + const float tone = tonal_weight(luma, shadows_value, midtones_value, highlights_value); + if (tone <= 0.00001f) + continue; + + const float reference_x = static_cast(x) * reference_scale_x; + const float reference_y = static_cast(y) * reference_scale_y; + const float fx = reference_x * fine_frequency; + const float fy = reference_y * fine_frequency; + const float sx = reference_x * soft_frequency; + const float sy = reference_y * soft_frequency; + const float cx = reference_x * clump_frequency; + const float cy = reference_y * clump_frequency; + + const int ix = static_cast(std::floor(fx)); + const int iy = static_cast(std::floor(fy)); + const auto grain_at_time = [&](uint32_t salt, int time_bucket) { + const float fine = hash_signed(base_seed, ix, iy, time_bucket, salt); + if (!use_softness) + return fine; + return lerp(fine, value_noise(base_seed, sx, sy, time_bucket, salt ^ 0x51633e2du), softness_value); + }; + const auto grain_sample = [&](uint32_t salt) { + float grain = grain_at_time(salt, time0); + if (use_temporal_blend) + grain = lerp(grain, grain_at_time(salt, time1), smooth_temporal_mix); + if (use_clump) { + const float cluster = value_noise(base_seed, cx, cy, time0, salt ^ 0xa511e9b3u); + grain *= lerp(1.0f, 0.45f + (std::abs(cluster) * 1.35f), clump_value); + } + return grain; + }; + + const float luma_grain = grain_sample(0x1000193u); + float red_grain = luma_grain; + float green_grain = luma_grain; + float blue_grain = luma_grain; + if (use_color_variation) { + red_grain = lerp(luma_grain, grain_sample(0x8da6b343u), color_variation_value); + green_grain = lerp(luma_grain, grain_sample(0xd8163841u), color_variation_value); + blue_grain = lerp(luma_grain, grain_sample(0xcb1ab31fu), color_variation_value); + } + const float strength = intensity_scale * tone; + + if (use_color) { + R = clamp01(R + (lerp(luma_grain, red_grain, color_amount_value) * strength)); + G = clamp01(G + (lerp(luma_grain, green_grain, color_amount_value) * strength)); + B = clamp01(B + (lerp(luma_grain, blue_grain, color_amount_value) * strength)); + } else { + const float delta = luma_grain * strength; + R = clamp01(R + delta); + G = clamp01(G + delta); + B = clamp01(B + delta); + } + + if (A == 255) { + row[idx + 0] = static_cast(clampByte(R * 255.0f)); + row[idx + 1] = static_cast(clampByte(G * 255.0f)); + row[idx + 2] = static_cast(clampByte(B * 255.0f)); + } else { + row[idx + 0] = static_cast(clampByte(R * 255.0f * alpha_percent)); + row[idx + 1] = static_cast(clampByte(G * 255.0f * alpha_percent)); + row[idx + 2] = static_cast(clampByte(B * 255.0f * alpha_percent)); + } + } + } + + return frame; +} + +std::string FilmGrain::Json() const { + return JsonValue().toStyledString(); +} + +Json::Value FilmGrain::JsonValue() const { + Json::Value root = EffectBase::JsonValue(); + root["type"] = info.class_name; + root["amount"] = amount.JsonValue(); + root["size"] = size.JsonValue(); + root["softness"] = softness.JsonValue(); + root["clump"] = clump.JsonValue(); + root["shadows"] = shadows.JsonValue(); + root["midtones"] = midtones.JsonValue(); + root["highlights"] = highlights.JsonValue(); + root["color_amount"] = color_amount.JsonValue(); + root["color_variation"] = color_variation.JsonValue(); + root["evolution"] = evolution.JsonValue(); + root["coherence"] = coherence.JsonValue(); + root["seed"] = seed; + return root; +} + +void FilmGrain::SetJson(const std::string value) { + try { + const Json::Value root = openshot::stringToJson(value); + SetJsonValue(root); + } catch (const std::exception&) { + throw InvalidJSON("Invalid JSON for FilmGrain effect"); + } +} + +void FilmGrain::SetJsonValue(const Json::Value root) { + EffectBase::SetJsonValue(root); + if (!root["amount"].isNull()) + amount.SetJsonValue(root["amount"]); + if (!root["size"].isNull()) + size.SetJsonValue(root["size"]); + if (!root["softness"].isNull()) + softness.SetJsonValue(root["softness"]); + if (!root["clump"].isNull()) + clump.SetJsonValue(root["clump"]); + if (!root["shadows"].isNull()) + shadows.SetJsonValue(root["shadows"]); + if (!root["midtones"].isNull()) + midtones.SetJsonValue(root["midtones"]); + if (!root["highlights"].isNull()) + highlights.SetJsonValue(root["highlights"]); + if (!root["color_amount"].isNull()) + color_amount.SetJsonValue(root["color_amount"]); + if (!root["color_variation"].isNull()) + color_variation.SetJsonValue(root["color_variation"]); + if (!root["evolution"].isNull()) + evolution.SetJsonValue(root["evolution"]); + if (!root["coherence"].isNull()) + coherence.SetJsonValue(root["coherence"]); + if (!root["seed"].isNull()) + seed = root["seed"].asInt(); +} + +std::string FilmGrain::PropertiesJSON(int64_t requested_frame) const { + Json::Value root = BasePropertiesJSON(requested_frame); + root["amount"] = add_property_json("Amount", amount.GetValue(requested_frame), "float", "Overall grain intensity.", &amount, 0.0, 1.0, false, requested_frame); + root["size"] = add_property_json("Size", size.GetValue(requested_frame), "float", "Fine to coarse grain scale.", &size, 0.0, 1.0, false, requested_frame); + root["softness"] = add_property_json("Softness", softness.GetValue(requested_frame), "float", "Hard crisp grain to softer organic grain.", &softness, 0.0, 1.0, false, requested_frame); + root["clump"] = add_property_json("Clump", clump.GetValue(requested_frame), "float", "Even grain to clustered irregular grain.", &clump, 0.0, 1.0, false, requested_frame); + root["shadows"] = add_property_json("Shadows", shadows.GetValue(requested_frame), "float", "Grain strength in dark regions.", &shadows, 0.0, 1.0, false, requested_frame); + root["midtones"] = add_property_json("Midtones", midtones.GetValue(requested_frame), "float", "Grain strength in middle tonal regions.", &midtones, 0.0, 1.0, false, requested_frame); + root["highlights"] = add_property_json("Highlights", highlights.GetValue(requested_frame), "float", "Grain strength in bright regions.", &highlights, 0.0, 1.0, false, requested_frame); + root["color_amount"] = add_property_json("Color Amount", color_amount.GetValue(requested_frame), "float", "How much grain affects chroma instead of mostly luma.", &color_amount, 0.0, 1.0, false, requested_frame); + root["color_variation"] = add_property_json("Color Variation", color_variation.GetValue(requested_frame), "float", "Correlated to independently varied color grain.", &color_variation, 0.0, 1.0, false, requested_frame); + root["evolution"] = add_property_json("Evolution", evolution.GetValue(requested_frame), "float", "How much grain renews over time.", &evolution, 0.0, 1.0, false, requested_frame); + root["coherence"] = add_property_json("Coherence", coherence.GetValue(requested_frame), "float", "How stable and smooth grain remains between frames.", &coherence, 0.0, 1.0, false, requested_frame); + root["seed"] = add_property_json("Seed", seed, "int", "Deterministic grain variation.", NULL, 0, 1000000, false, requested_frame); + return root.toStyledString(); +} diff --git a/src/effects/FilmGrain.h b/src/effects/FilmGrain.h new file mode 100644 index 000000000..753c7293d --- /dev/null +++ b/src/effects/FilmGrain.h @@ -0,0 +1,68 @@ +/** + * @file + * @brief Header file for FilmGrain effect + * @author Jonathan Thomas + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#ifndef OPENSHOT_FILM_GRAIN_EFFECT_H +#define OPENSHOT_FILM_GRAIN_EFFECT_H + +#include "../EffectBase.h" +#include "../Frame.h" +#include "../Json.h" +#include "../KeyFrame.h" + +#include +#include + +namespace openshot +{ + /** + * @brief Creative film-inspired grain texture effect. + * + * FilmGrain exposes a compact set of visible controls for structure, + * tonal response, color behavior, temporal behavior, and deterministic + * variation. Presets should initialize these same properties rather than + * selecting hidden internal modes. + */ + class FilmGrain : public EffectBase + { + private: + void init_effect_details(); + + public: + Keyframe amount; ///< Overall grain intensity. + Keyframe size; ///< Fine to coarse grain scale. + Keyframe softness; ///< Crisp vs soft organic grain. + Keyframe clump; ///< Even vs clustered irregular structure. + Keyframe shadows; ///< Grain strength in dark regions. + Keyframe midtones; ///< Grain strength in middle tonal regions. + Keyframe highlights; ///< Grain strength in bright regions. + Keyframe color_amount; ///< Chroma contribution amount. + Keyframe color_variation; ///< RGB channel independence. + Keyframe evolution; ///< How much the grain renews over time. + Keyframe coherence; ///< Smoothness/stability between frames. + int seed; ///< Deterministic grain identity. + + FilmGrain(); + + 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 f38ba5f0f..873265922 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -57,6 +57,7 @@ set(OPENSHOT_TESTS ChromaKey Crop Displace + FilmGrain LensFlare AnalogTape BenchmarkArgs diff --git a/tests/FilmGrain.cpp b/tests/FilmGrain.cpp new file mode 100644 index 000000000..5ea318db5 --- /dev/null +++ b/tests/FilmGrain.cpp @@ -0,0 +1,264 @@ +/** + * @file + * @brief Unit tests for FilmGrain effect + * + * @ref License + */ + +// Copyright (c) 2008-2026 OpenShot Studios, LLC +// +// SPDX-License-Identifier: LGPL-3.0-or-later + +#include +#include +#include +#include +#include + +#include "Frame.h" +#include "effects/FilmGrain.h" +#include "openshot_catch.h" + +using namespace openshot; + +static std::shared_ptr makeFilmGrainFrame(int w = 32, int h = 32, const QColor& color = QColor(128, 128, 128, 255)) +{ + auto frame = std::make_shared(1, w, h, "#000000", 0, 2); + auto img = std::make_shared(w, h, QImage::Format_RGBA8888_Premultiplied); + img->fill(color); + frame->AddImage(img); + return frame; +} + +static int imageDifference(const QImage& a, const QImage& b) +{ + int total = 0; + for (int y = 0; y < a.height(); ++y) { + for (int x = 0; x < a.width(); ++x) { + const QColor ca = a.pixelColor(x, y); + const QColor cb = b.pixelColor(x, y); + total += std::abs(ca.red() - cb.red()); + total += std::abs(ca.green() - cb.green()); + total += std::abs(ca.blue() - cb.blue()); + } + } + return total; +} + +TEST_CASE("FilmGrain modifies frame", "[effect][filmgrain]") +{ + FilmGrain effect; + effect.Id("filmgrain-modifies"); + effect.amount = Keyframe(0.8); + effect.evolution = Keyframe(0.0); + auto frame = makeFilmGrainFrame(); + const QImage before = frame->GetImage()->copy(); + auto out = effect.GetFrame(frame, 1); + CHECK(imageDifference(before, *out->GetImage()) > 0); +} + +TEST_CASE("FilmGrain deterministic for same id and seed", "[effect][filmgrain]") +{ + FilmGrain e1; + e1.Id("same"); + e1.seed = 42; + e1.amount = Keyframe(0.7); + e1.evolution = Keyframe(0.0); + + FilmGrain e2; + e2.Id("same"); + e2.seed = 42; + e2.amount = Keyframe(0.7); + e2.evolution = Keyframe(0.0); + + auto out1 = e1.GetFrame(makeFilmGrainFrame(), 12); + auto out2 = e2.GetFrame(makeFilmGrainFrame(), 12); + CHECK(imageDifference(*out1->GetImage(), *out2->GetImage()) == 0); +} + +TEST_CASE("FilmGrain seed alters output", "[effect][filmgrain]") +{ + FilmGrain e1; + e1.Id("seed"); + e1.seed = 1; + e1.amount = Keyframe(0.7); + e1.evolution = Keyframe(0.0); + + FilmGrain e2; + e2.Id("seed"); + e2.seed = 2; + e2.amount = Keyframe(0.7); + e2.evolution = Keyframe(0.0); + + auto out1 = e1.GetFrame(makeFilmGrainFrame(), 1); + auto out2 = e2.GetFrame(makeFilmGrainFrame(), 1); + CHECK(imageDifference(*out1->GetImage(), *out2->GetImage()) > 0); +} + +TEST_CASE("FilmGrain JSON and properties expose visible controls", "[effect][filmgrain][json]") +{ + FilmGrain effect; + effect.amount = Keyframe(0.5); + effect.size = Keyframe(0.4); + effect.color_amount = Keyframe(0.25); + effect.seed = 9876; + + FilmGrain copy; + copy.SetJson(effect.Json()); + CHECK(copy.amount.GetValue(1) == Approx(0.5)); + CHECK(copy.size.GetValue(1) == Approx(0.4)); + CHECK(copy.color_amount.GetValue(1) == Approx(0.25)); + CHECK(copy.seed == 9876); + + std::istringstream props(copy.PropertiesJSON(1)); + Json::CharReaderBuilder rb; + Json::Value root; + std::string errs; + REQUIRE(Json::parseFromStream(rb, props, &root, &errs)); + CHECK(root.isMember("amount")); + CHECK(root.isMember("size")); + CHECK(root.isMember("softness")); + CHECK(root.isMember("clump")); + CHECK(root.isMember("shadows")); + CHECK(root.isMember("midtones")); + CHECK(root.isMember("highlights")); + CHECK(root.isMember("color_amount")); + CHECK(root.isMember("color_variation")); + CHECK(root.isMember("evolution")); + CHECK(root.isMember("coherence")); + CHECK(root.isMember("seed")); + CHECK(root["seed"]["type"].asString() == "int"); +} + +TEST_CASE("FilmGrain tonal controls weight selected luma range", "[effect][filmgrain]") +{ + FilmGrain effect; + effect.Id("tonal"); + effect.seed = 7; + effect.amount = Keyframe(1.0); + effect.size = Keyframe(0.0); + effect.softness = Keyframe(0.0); + effect.clump = Keyframe(0.0); + effect.evolution = Keyframe(0.0); + effect.color_amount = Keyframe(0.0); + effect.shadows = Keyframe(1.0); + effect.midtones = Keyframe(0.0); + effect.highlights = Keyframe(0.0); + + auto dark = makeFilmGrainFrame(32, 32, QColor(40, 40, 40, 255)); + auto light = makeFilmGrainFrame(32, 32, QColor(220, 220, 220, 255)); + const QImage dark_before = dark->GetImage()->copy(); + const QImage light_before = light->GetImage()->copy(); + + auto dark_out = effect.GetFrame(dark, 1); + auto light_out = effect.GetFrame(light, 1); + CHECK(imageDifference(dark_before, *dark_out->GetImage()) > imageDifference(light_before, *light_out->GetImage())); +} + +TEST_CASE("FilmGrain amount zero is identity", "[effect][filmgrain]") +{ + FilmGrain effect; + effect.amount = Keyframe(0.0); + auto frame = makeFilmGrainFrame(); + const QImage before = frame->GetImage()->copy(); + auto out = effect.GetFrame(frame, 1); + CHECK(imageDifference(before, *out->GetImage()) == 0); +} + +TEST_CASE("FilmGrain structure controls affect grain identity", "[effect][filmgrain]") +{ + FilmGrain baseline; + baseline.Id("structure"); + baseline.seed = 22; + baseline.amount = Keyframe(0.9); + baseline.size = Keyframe(0.0); + baseline.softness = Keyframe(0.0); + baseline.clump = Keyframe(0.0); + baseline.evolution = Keyframe(0.0); + + FilmGrain coarse = baseline; + coarse.size = Keyframe(1.0); + FilmGrain soft = baseline; + soft.softness = Keyframe(1.0); + FilmGrain clumped = baseline; + clumped.clump = Keyframe(1.0); + + auto base_out = baseline.GetFrame(makeFilmGrainFrame(), 1); + CHECK(imageDifference(*base_out->GetImage(), *coarse.GetFrame(makeFilmGrainFrame(), 1)->GetImage()) > 0); + CHECK(imageDifference(*base_out->GetImage(), *soft.GetFrame(makeFilmGrainFrame(), 1)->GetImage()) > 0); + CHECK(imageDifference(*base_out->GetImage(), *clumped.GetFrame(makeFilmGrainFrame(), 1)->GetImage()) > 0); +} + +TEST_CASE("FilmGrain midtones and highlights can be targeted", "[effect][filmgrain]") +{ + FilmGrain effect; + effect.Id("tone-targets"); + effect.seed = 8; + effect.amount = Keyframe(1.0); + effect.evolution = Keyframe(0.0); + effect.color_amount = Keyframe(0.0); + + effect.shadows = Keyframe(0.0); + effect.midtones = Keyframe(1.0); + effect.highlights = Keyframe(0.0); + auto mid = makeFilmGrainFrame(32, 32, QColor(128, 128, 128, 255)); + auto light = makeFilmGrainFrame(32, 32, QColor(235, 235, 235, 255)); + const QImage mid_before = mid->GetImage()->copy(); + const QImage light_before = light->GetImage()->copy(); + CHECK(imageDifference(mid_before, *effect.GetFrame(mid, 1)->GetImage()) > + imageDifference(light_before, *effect.GetFrame(light, 1)->GetImage())); + + effect.midtones = Keyframe(0.0); + effect.highlights = Keyframe(1.0); + auto dark = makeFilmGrainFrame(32, 32, QColor(32, 32, 32, 255)); + light = makeFilmGrainFrame(32, 32, QColor(235, 235, 235, 255)); + const QImage dark_before = dark->GetImage()->copy(); + const QImage highlight_before = light->GetImage()->copy(); + CHECK(imageDifference(highlight_before, *effect.GetFrame(light, 1)->GetImage()) > + imageDifference(dark_before, *effect.GetFrame(dark, 1)->GetImage())); +} + +TEST_CASE("FilmGrain color controls vary channel correlation", "[effect][filmgrain]") +{ + FilmGrain luma_only; + luma_only.Id("color"); + luma_only.seed = 55; + luma_only.amount = Keyframe(1.0); + luma_only.evolution = Keyframe(0.0); + luma_only.color_amount = Keyframe(0.0); + luma_only.color_variation = Keyframe(1.0); + + FilmGrain color_grain = luma_only; + color_grain.color_amount = Keyframe(1.0); + + auto luma_out = luma_only.GetFrame(makeFilmGrainFrame(), 1); + auto color_out = color_grain.GetFrame(makeFilmGrainFrame(), 1); + const QColor luma_pixel = luma_out->GetImage()->pixelColor(3, 3); + const QColor color_pixel = color_out->GetImage()->pixelColor(3, 3); + + CHECK(luma_pixel.red() == luma_pixel.green()); + CHECK(luma_pixel.green() == luma_pixel.blue()); + CHECK((color_pixel.red() != color_pixel.green() || color_pixel.green() != color_pixel.blue())); + CHECK(imageDifference(*luma_out->GetImage(), *color_out->GetImage()) > 0); +} + +TEST_CASE("FilmGrain temporal controls affect frame-to-frame variation", "[effect][filmgrain]") +{ + FilmGrain static_grain; + static_grain.Id("temporal"); + static_grain.seed = 9; + static_grain.amount = Keyframe(0.8); + static_grain.evolution = Keyframe(0.0); + + FilmGrain evolving = static_grain; + evolving.evolution = Keyframe(1.0); + evolving.coherence = Keyframe(0.0); + + auto static_a = static_grain.GetFrame(makeFilmGrainFrame(), 1); + auto static_b = static_grain.GetFrame(makeFilmGrainFrame(), 30); + auto evolving_a = evolving.GetFrame(makeFilmGrainFrame(), 1); + auto evolving_b = evolving.GetFrame(makeFilmGrainFrame(), 30); + + CHECK(imageDifference(*static_a->GetImage(), *static_b->GetImage()) == 0); + CHECK(imageDifference(*evolving_a->GetImage(), *evolving_b->GetImage()) > 0); +} From 2a9a480870d11e8a24e4e2ab5ae39a0ac6dc5e06 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Tue, 28 Apr 2026 13:27:08 -0500 Subject: [PATCH 2/3] Fixed a long standing bug related to CROP scale mode, where -1/+1 location_x/y did not correctly calculate the offscreen coordinates. This will have backwards compatibility implications, since old projects that combine custom location_x/y + CROP scale mode will calculate position differently now. Added tests: - location_x/y = -1/+1 offscreen behavior for all scale enums and multiple scale_x/y combinations. - all 9 gravity anchors when location_x/y = 0, including sub-100% and non-uniform scale. - Crop-specific intermediate location behavior so the original bug stays covered. --- src/Clip.cpp | 12 +- tests/Clip.cpp | 402 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 2 deletions(-) diff --git a/src/Clip.cpp b/src/Clip.cpp index c9e299661..645e89a7e 100644 --- a/src/Clip.cpp +++ b/src/Clip.cpp @@ -1577,8 +1577,16 @@ QTransform Clip::get_transform(std::shared_ptr frame, int width, int heig /* LOCATION, ROTATION, AND SCALE */ float r = rotation.GetValue(frame->number) + parentObject_rotation; // rotate in degrees - x += width * (location_x.GetValue(frame->number) + parentObject_location_x); // move in percentage of final width - y += height * (location_y.GetValue(frame->number) + parentObject_location_y); // move in percentage of final height + float location_x_value = location_x.GetValue(frame->number) + parentObject_location_x; + float location_y_value = location_y.GetValue(frame->number) + parentObject_location_y; + auto location_offset = [](float location, float anchored_position, float canvas_size, float clip_size) { + if (location < 0.0f) { + return location * (anchored_position + clip_size); + } + return location * (canvas_size - anchored_position); + }; + x += location_offset(location_x_value, x, width, scaled_source_width); + y += location_offset(location_y_value, y, height, scaled_source_height); float shear_x_value = shear_x.GetValue(frame->number) + parentObject_shear_x; float shear_y_value = shear_y.GetValue(frame->number) + parentObject_shear_y; float origin_x_value = origin_x.GetValue(frame->number); diff --git a/tests/Clip.cpp b/tests/Clip.cpp index a2d005074..c2c9837af 100644 --- a/tests/Clip.cpp +++ b/tests/Clip.cpp @@ -17,7 +17,10 @@ #include "openshot_catch.h" #include +#include +#include #include +#include #include #include #include @@ -34,12 +37,147 @@ #include "Frame.h" #include "Fraction.h" #include "FrameMapper.h" +#include "QtImageReader.h" #include "Timeline.h" #include "Json.h" #include "effects/Negate.h" using namespace openshot; +namespace { + struct ClipTransformCase { + ScaleType scale; + double scale_x; + double scale_y; + }; + + QRect visible_bounds(const QImage& image) { + int left = image.width(); + int top = image.height(); + int right = -1; + int bottom = -1; + + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + if (image.pixelColor(x, y).alpha() > 0) { + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + } + } + + if (right < left || bottom < top) { + return QRect(); + } + return QRect(QPoint(left, top), QPoint(right, bottom)); + } + + QRect red_bounds(const QImage& image) { + int left = image.width(); + int top = image.height(); + int right = -1; + int bottom = -1; + + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor color = image.pixelColor(x, y); + if (color.red() > 200 && color.green() < 50 && color.blue() < 50) { + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + } + } + + if (right < left || bottom < top) { + return QRect(); + } + return QRect(QPoint(left, top), QPoint(right, bottom)); + } + + QSize expected_scaled_size(QSize source_size, ScaleType scale, int canvas_width, int canvas_height) { + switch (scale) { + case SCALE_FIT: + source_size.scale(canvas_width, canvas_height, Qt::KeepAspectRatio); + break; + case SCALE_STRETCH: + source_size.scale(canvas_width, canvas_height, Qt::IgnoreAspectRatio); + break; + case SCALE_CROP: + source_size.scale(canvas_width, canvas_height, Qt::KeepAspectRatioByExpanding); + break; + case SCALE_NONE: + break; + } + return source_size; + } + + double expected_gravity_x(GravityType gravity, double canvas_width, double clip_width) { + switch (gravity) { + case GRAVITY_TOP: + case GRAVITY_CENTER: + case GRAVITY_BOTTOM: + return (canvas_width - clip_width) / 2.0; + case GRAVITY_TOP_RIGHT: + case GRAVITY_RIGHT: + case GRAVITY_BOTTOM_RIGHT: + return canvas_width - clip_width; + case GRAVITY_TOP_LEFT: + case GRAVITY_LEFT: + case GRAVITY_BOTTOM_LEFT: + return 0.0; + } + return 0.0; + } + + double expected_gravity_y(GravityType gravity, double canvas_height, double clip_height) { + switch (gravity) { + case GRAVITY_LEFT: + case GRAVITY_CENTER: + case GRAVITY_RIGHT: + return (canvas_height - clip_height) / 2.0; + case GRAVITY_BOTTOM_LEFT: + case GRAVITY_BOTTOM: + case GRAVITY_BOTTOM_RIGHT: + return canvas_height - clip_height; + case GRAVITY_TOP_LEFT: + case GRAVITY_TOP: + case GRAVITY_TOP_RIGHT: + return 0.0; + } + return 0.0; + } + + QRect render_clip_bounds(Clip& clip, int canvas_width, int canvas_height) { + clip.GetCache()->Clear(); + auto background = std::make_shared(1, canvas_width, canvas_height, "#00000000", 0, 2); + background->AddColor(QColor(Qt::transparent)); + auto output = clip.GetFrame(background, 1); + return visible_bounds(*output->GetImage()); + } + + void check_location_endpoints_offscreen(Clip& clip, int canvas_width, int canvas_height) { + clip.location_x = openshot::Keyframe(-1.0); + clip.location_y = openshot::Keyframe(0.0); + CHECK(render_clip_bounds(clip, canvas_width, canvas_height).isNull()); + + clip.location_x = openshot::Keyframe(1.0); + clip.location_y = openshot::Keyframe(0.0); + CHECK(render_clip_bounds(clip, canvas_width, canvas_height).isNull()); + + clip.location_x = openshot::Keyframe(0.0); + clip.location_y = openshot::Keyframe(-1.0); + CHECK(render_clip_bounds(clip, canvas_width, canvas_height).isNull()); + + clip.location_x = openshot::Keyframe(0.0); + clip.location_y = openshot::Keyframe(1.0); + CHECK(render_clip_bounds(clip, canvas_width, canvas_height).isNull()); + } +} + TEST_CASE( "default constructor", "[libopenshot][clip]" ) { // Create a empty clip @@ -1030,6 +1168,270 @@ TEST_CASE("all_composite_modes_simple_colors", "[libopenshot][clip][composite]") } } +TEST_CASE("clip_location_minus_one_plus_one_places_scaled_clip_offscreen", "[libopenshot][clip][transform]") +{ + const int canvas_w = 160; + const int canvas_h = 90; + const std::vector source_sizes = { + QSize(40, 30), + QSize(40, 40), + }; + + const std::vector cases = { + {openshot::SCALE_FIT, 1.0, 1.0}, + {openshot::SCALE_CROP, 1.0, 1.0}, + {openshot::SCALE_STRETCH, 1.0, 1.0}, + {openshot::SCALE_NONE, 1.0, 1.0}, + {openshot::SCALE_FIT, 0.5, 0.75}, + {openshot::SCALE_CROP, 0.5, 0.75}, + {openshot::SCALE_STRETCH, 0.5, 0.75}, + {openshot::SCALE_NONE, 0.5, 0.75}, + {openshot::SCALE_FIT, 1.25, 0.6}, + {openshot::SCALE_CROP, 1.25, 0.6}, + {openshot::SCALE_STRETCH, 1.25, 0.6}, + {openshot::SCALE_NONE, 1.25, 0.6}, + }; + + for (const auto& source_size : source_sizes) { + INFO("source=" << source_size.width() << "x" << source_size.height()); + openshot::CacheMemory cache; + auto src = std::make_shared(1, source_size.width(), source_size.height(), "#00000000", 0, 2); + src->AddColor(QColor(Qt::red)); + cache.Add(src); + + openshot::DummyReader dummy(openshot::Fraction(30, 1), source_size.width(), source_size.height(), 44100, 2, 1.0, &cache); + dummy.Open(); + + openshot::Clip clip; + clip.Reader(&dummy); + clip.Open(); + clip.display = openshot::FRAME_DISPLAY_NONE; + clip.gravity = openshot::GRAVITY_CENTER; + + for (const auto& c : cases) { + INFO("scale=" << c.scale << " scale_x=" << c.scale_x << " scale_y=" << c.scale_y); + clip.scale = c.scale; + clip.scale_x = openshot::Keyframe(c.scale_x); + clip.scale_y = openshot::Keyframe(c.scale_y); + check_location_endpoints_offscreen(clip, canvas_w, canvas_h); + } + } +} + +TEST_CASE("clip_location_endpoints_offscreen_for_qt_square_image_reader", "[libopenshot][clip][transform]") +{ + const int canvas_w = 160; + const int canvas_h = 90; + const QString path = QDir::tempPath() + "/libopenshot_square_transform.png"; + + QImage source(40, 40, QImage::Format_RGBA8888_Premultiplied); + source.fill(QColor(Qt::red)); + REQUIRE(source.save(path, "PNG")); + + openshot::QtImageReader reader(path.toStdString()); + openshot::Clip clip; + clip.Reader(&reader); + clip.Open(); + clip.display = openshot::FRAME_DISPLAY_NONE; + clip.gravity = openshot::GRAVITY_CENTER; + + openshot::Timeline timeline(canvas_w, canvas_h, openshot::Fraction(30, 1), 44100, 2, LAYOUT_STEREO); + timeline.SetMaxSize(canvas_w, canvas_h); + clip.ParentTimeline(&timeline); + + const std::vector scales = { + openshot::SCALE_FIT, + openshot::SCALE_CROP, + }; + + for (auto scale : scales) { + INFO("scale=" << scale); + clip.scale = scale; + clip.scale_x = openshot::Keyframe(1.0); + clip.scale_y = openshot::Keyframe(1.0); + check_location_endpoints_offscreen(clip, canvas_w, canvas_h); + } + + clip.Close(); + QFile::remove(path); +} + +TEST_CASE("timeline_location_y_endpoints_offscreen_for_qt_square_image_reader", "[libopenshot][clip][transform][timeline]") +{ + const int canvas_w = 160; + const int canvas_h = 90; + const QString path = QDir::tempPath() + "/libopenshot_square_timeline_transform.png"; + + QImage source(40, 40, QImage::Format_RGBA8888_Premultiplied); + source.fill(QColor(Qt::red)); + REQUIRE(source.save(path, "PNG")); + + openshot::QtImageReader reader(path.toStdString()); + openshot::Clip clip; + clip.Reader(&reader); + clip.Open(); + clip.display = openshot::FRAME_DISPLAY_NONE; + clip.gravity = openshot::GRAVITY_CENTER; + clip.Position(0.0); + clip.Start(0.0); + clip.End(1.0); + + openshot::Timeline timeline(canvas_w, canvas_h, openshot::Fraction(30, 1), 44100, 2, LAYOUT_STEREO); + timeline.SetMaxSize(canvas_w, canvas_h); + timeline.AddClip(&clip); + timeline.Open(); + + const std::vector scales = { + openshot::SCALE_FIT, + openshot::SCALE_CROP, + }; + + for (auto scale : scales) { + INFO("scale=" << scale); + clip.scale = scale; + clip.scale_x = openshot::Keyframe(1.0); + clip.scale_y = openshot::Keyframe(1.0); + + clip.GetCache()->Clear(); + timeline.GetCache()->Clear(); + clip.location_x = openshot::Keyframe(0.0); + clip.location_y = openshot::Keyframe(0.0); + CHECK_FALSE(red_bounds(*timeline.GetFrame(1)->GetImage()).isNull()); + + clip.GetCache()->Clear(); + timeline.GetCache()->Clear(); + clip.location_y = openshot::Keyframe(-1.0); + CHECK(red_bounds(*timeline.GetFrame(1)->GetImage()).isNull()); + + clip.GetCache()->Clear(); + timeline.GetCache()->Clear(); + clip.location_y = openshot::Keyframe(1.0); + CHECK(red_bounds(*timeline.GetFrame(1)->GetImage()).isNull()); + } + + timeline.Close(); + clip.Close(); + QFile::remove(path); +} + +TEST_CASE("clip_gravity_anchors_scaled_clip_when_location_is_zero", "[libopenshot][clip][transform]") +{ + const int source_w = 40; + const int source_h = 30; + const int canvas_w = 160; + const int canvas_h = 90; + + openshot::CacheMemory cache; + auto src = std::make_shared(1, source_w, source_h, "#00000000", 0, 2); + src->AddColor(QColor(Qt::red)); + cache.Add(src); + + openshot::DummyReader dummy(openshot::Fraction(30, 1), source_w, source_h, 44100, 2, 1.0, &cache); + dummy.Open(); + + openshot::Clip clip; + clip.Reader(&dummy); + clip.Open(); + clip.display = openshot::FRAME_DISPLAY_NONE; + clip.location_x = openshot::Keyframe(0.0); + clip.location_y = openshot::Keyframe(0.0); + + const std::vector gravities = { + openshot::GRAVITY_TOP_LEFT, + openshot::GRAVITY_TOP, + openshot::GRAVITY_TOP_RIGHT, + openshot::GRAVITY_LEFT, + openshot::GRAVITY_CENTER, + openshot::GRAVITY_RIGHT, + openshot::GRAVITY_BOTTOM_LEFT, + openshot::GRAVITY_BOTTOM, + openshot::GRAVITY_BOTTOM_RIGHT, + }; + const std::vector scales = { + openshot::SCALE_FIT, + openshot::SCALE_CROP, + openshot::SCALE_STRETCH, + openshot::SCALE_NONE, + }; + const std::vector> scale_factors = { + {0.5, 0.5}, + {0.25, 0.75}, + }; + + for (auto scale : scales) { + for (auto gravity : gravities) { + for (const auto& factor : scale_factors) { + INFO("scale=" << scale << " gravity=" << gravity + << " scale_x=" << factor.first << " scale_y=" << factor.second); + clip.scale = scale; + clip.gravity = gravity; + clip.scale_x = openshot::Keyframe(factor.first); + clip.scale_y = openshot::Keyframe(factor.second); + + QSize base = expected_scaled_size(QSize(source_w, source_h), scale, canvas_w, canvas_h); + const double expected_w = base.width() * factor.first; + const double expected_h = base.height() * factor.second; + const double expected_x = expected_gravity_x(gravity, canvas_w, expected_w); + const double expected_y = expected_gravity_y(gravity, canvas_h, expected_h); + + QRect bounds = render_clip_bounds(clip, canvas_w, canvas_h); + REQUIRE_FALSE(bounds.isNull()); + CHECK(bounds.left() == Approx(expected_x).margin(2.0)); + CHECK(bounds.top() == Approx(expected_y).margin(2.0)); + CHECK(bounds.width() == Approx(expected_w).margin(2.0)); + CHECK(bounds.height() == Approx(expected_h).margin(2.0)); + } + } + } +} + +TEST_CASE("clip_location_uses_distance_from_gravity_anchor_to_offscreen_edge", "[libopenshot][clip][transform]") +{ + const int source_w = 40; + const int source_h = 30; + const int canvas_w = 160; + const int canvas_h = 90; + + openshot::CacheMemory cache; + auto src = std::make_shared(1, source_w, source_h, "#00000000", 0, 2); + src->AddColor(QColor(Qt::red)); + cache.Add(src); + + openshot::DummyReader dummy(openshot::Fraction(30, 1), source_w, source_h, 44100, 2, 1.0, &cache); + dummy.Open(); + + openshot::Clip clip; + clip.Reader(&dummy); + clip.Open(); + clip.display = openshot::FRAME_DISPLAY_NONE; + clip.gravity = openshot::GRAVITY_CENTER; + clip.scale = openshot::SCALE_CROP; + clip.scale_x = openshot::Keyframe(1.0); + clip.scale_y = openshot::Keyframe(1.0); + + QSize base = expected_scaled_size(QSize(source_w, source_h), openshot::SCALE_CROP, canvas_w, canvas_h); + const double expected_w = base.width(); + const double expected_h = base.height(); + const double anchor_x = expected_gravity_x(openshot::GRAVITY_CENTER, canvas_w, expected_w); + const double anchor_y = expected_gravity_y(openshot::GRAVITY_CENTER, canvas_h, expected_h); + + clip.location_x = openshot::Keyframe(0.5); + clip.location_y = openshot::Keyframe(0.5); + QRect positive = render_clip_bounds(clip, canvas_w, canvas_h); + REQUIRE_FALSE(positive.isNull()); + CHECK(positive.left() == Approx(anchor_x + ((canvas_w - anchor_x) * 0.5)).margin(2.0)); + CHECK(positive.top() == Approx(anchor_y + ((canvas_h - anchor_y) * 0.5)).margin(2.0)); + + clip.location_x = openshot::Keyframe(-0.5); + clip.location_y = openshot::Keyframe(-0.5); + QRect negative = render_clip_bounds(clip, canvas_w, canvas_h); + REQUIRE_FALSE(negative.isNull()); + CHECK(negative.left() == 0); + CHECK(negative.top() == 0); + CHECK(negative.width() == Approx((anchor_x + expected_w) * 0.5).margin(2.0)); + CHECK(negative.height() == Approx((anchor_y + expected_h) * 0.5).margin(2.0)); +} + TEST_CASE( "transform_path_identity_vs_scaled", "[libopenshot][clip][pr]" ) { // Create a small checker-ish image to make scaling detectable From 3b765ae5c1110383563116bf5fd702205f5d3c32 Mon Sep 17 00:00:00 2001 From: Jonathan Thomas Date: Fri, 1 May 2026 19:44:40 -0500 Subject: [PATCH 3/3] =?UTF-8?q?Optimize=20FilmGrain=20hot=20path=20?= =?UTF-8?q?=E2=80=94=20~6=C3=97=20fewer=20hash=20calls=20per=20pixel=20-?= =?UTF-8?q?=20Cache=20y-pre-lerped=20bilinear=20columns=20for=20soft=20and?= =?UTF-8?q?=20clump=20noise,=20refreshing=20only=20when=20the=20x-cell=20i?= =?UTF-8?q?ndex=20changes=20(~every=207=20px=20for=20soft,=20~every=209=20?= =?UTF-8?q?px=20for=20clump=20at=20defaults).=20Fine=20grain=20hash=20is?= =?UTF-8?q?=20similarly=20cached=20per=20cell.=20This=20cuts=20amortized?= =?UTF-8?q?=20hash=5Fcoords=20+=20mix32=20calls=20from=20~56=20to=20~9=20p?= =?UTF-8?q?er=20pixel=20in=20the=20fully-featured=20case.=20-=20hoist=20al?= =?UTF-8?q?l=20y-dependent=20values=20out=20of=20the=20x-loop;=20replace?= =?UTF-8?q?=20std::round=20in=20clampByte;=20extract=20salt=20magic=20numb?= =?UTF-8?q?ers=20to=20named=20constants;=20move=20alpha=5Fpercent=20to=20t?= =?UTF-8?q?he=20write-back=20branch=20where=20it's=20actually=20needed;=20?= =?UTF-8?q?collapse=20the=20color-apply=20branch=20to=20use=5Fcolor=5Fvari?= =?UTF-8?q?ation=20directly.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/effects/FilmGrain.cpp | 365 +++++++++++++++++++++++--------------- 1 file changed, 221 insertions(+), 144 deletions(-) diff --git a/src/effects/FilmGrain.cpp b/src/effects/FilmGrain.cpp index e0fad3d06..b41ebe262 100644 --- a/src/effects/FilmGrain.cpp +++ b/src/effects/FilmGrain.cpp @@ -23,7 +23,16 @@ using namespace openshot; namespace { -constexpr float kInv255 = 1.0f / 255.0f; +constexpr float kInv255 = 1.0f / 255.0f; +constexpr float kInvU32Max = 2.0f / 4294967295.0f; // multiply instead of divide in hash_signed + +// Salt constants for each grain channel and noise layer +constexpr uint32_t kSaltLuma = 0x1000193u; +constexpr uint32_t kSaltRed = 0x8da6b343u; +constexpr uint32_t kSaltGreen = 0xd8163841u; +constexpr uint32_t kSaltBlue = 0xcb1ab31fu; +constexpr uint32_t kSoftXor = 0x51633e2du; +constexpr uint32_t kClumpXor = 0xa511e9b3u; static float clamp01(float value) { return std::max(0.0f, std::min(1.0f, value)); @@ -34,7 +43,7 @@ static int clampByte(float value) { return 0; if (value >= 255.0f) return 255; - return static_cast(std::round(value)); + return static_cast(value + 0.5f); } static float lerp(float a, float b, float t) { @@ -68,26 +77,7 @@ static uint32_t hash_coords(uint32_t seed, int x, int y, int t, uint32_t salt) { } static float hash_signed(uint32_t seed, int x, int y, int t, uint32_t salt) { - return (hash_coords(seed, x, y, t, salt) / 4294967295.0f) * 2.0f - 1.0f; -} - -static float value_noise(uint32_t seed, float x, float y, int time_bucket, uint32_t salt) { - const int x0 = static_cast(std::floor(x)); - const int y0 = static_cast(std::floor(y)); - const int x1 = x0 + 1; - const int y1 = y0 + 1; - const float tx = x - static_cast(x0); - const float ty = y - static_cast(y0); - const float sx = tx * tx * (3.0f - 2.0f * tx); - const float sy = ty * ty * (3.0f - 2.0f * ty); - - const float n00 = hash_signed(seed, x0, y0, time_bucket, salt); - const float n10 = hash_signed(seed, x1, y0, time_bucket, salt); - const float n01 = hash_signed(seed, x0, y1, time_bucket, salt); - const float n11 = hash_signed(seed, x1, y1, time_bucket, salt); - const float nx0 = lerp(n00, n10, sx); - const float nx1 = lerp(n01, n11, sx); - return lerp(nx0, nx1, sy); + return hash_coords(seed, x, y, t, salt) * kInvU32Max - 1.0f; } static float tonal_weight(float luma, float shadow_strength, float midtone_strength, float highlight_strength) { @@ -134,34 +124,40 @@ std::shared_ptr FilmGrain::GetFrame(std::shared_ptr(size.GetValue(frame_number))); - const float softness_value = clamp01(static_cast(softness.GetValue(frame_number))); - const float clump_value = clamp01(static_cast(clump.GetValue(frame_number))); - const float shadows_value = clamp01(static_cast(shadows.GetValue(frame_number))); - const float midtones_value = clamp01(static_cast(midtones.GetValue(frame_number))); - const float highlights_value = clamp01(static_cast(highlights.GetValue(frame_number))); - const float color_amount_value = clamp01(static_cast(color_amount.GetValue(frame_number))); + const float size_value = clamp01(static_cast(size.GetValue(frame_number))); + const float softness_value = clamp01(static_cast(softness.GetValue(frame_number))); + const float clump_value = clamp01(static_cast(clump.GetValue(frame_number))); + const float shadows_value = clamp01(static_cast(shadows.GetValue(frame_number))); + const float midtones_value = clamp01(static_cast(midtones.GetValue(frame_number))); + const float highlights_value = clamp01(static_cast(highlights.GetValue(frame_number))); + const float color_amount_value = clamp01(static_cast(color_amount.GetValue(frame_number))); const float color_variation_value = clamp01(static_cast(color_variation.GetValue(frame_number))); - const float evolution_value = clamp01(static_cast(evolution.GetValue(frame_number))); - const float coherence_value = clamp01(static_cast(coherence.GetValue(frame_number))); + const float evolution_value = clamp01(static_cast(evolution.GetValue(frame_number))); + const float coherence_value = clamp01(static_cast(coherence.GetValue(frame_number))); - const float cell_size = lerp(1.0f, 9.0f, size_value); - const float fine_frequency = 1.0f / cell_size; - const float soft_frequency = fine_frequency * lerp(0.45f, 0.12f, softness_value); + const float cell_size = lerp(1.0f, 9.0f, size_value); + const float fine_frequency = 1.0f / cell_size; + const float soft_frequency = fine_frequency * lerp(0.45f, 0.12f, softness_value); const float clump_frequency = fine_frequency * lerp(0.35f, 0.08f, clump_value); - const float temporal_rate = lerp(0.0f, 1.0f, evolution_value) * lerp(1.0f, 8.0f, 1.0f - coherence_value); - const float temporal_position = static_cast(frame_number) * temporal_rate; - const int time0 = static_cast(std::floor(temporal_position)); - const int time1 = time0 + 1; - const float temporal_mix = temporal_rate <= 0.00001f ? 0.0f : clamp01(temporal_position - static_cast(time0)); + const float temporal_rate = lerp(0.0f, 1.0f, evolution_value) * lerp(1.0f, 8.0f, 1.0f - coherence_value); + const float temporal_position = static_cast(frame_number) * temporal_rate; + const int time0 = static_cast(std::floor(temporal_position)); + const int time1 = time0 + 1; + const float temporal_mix = temporal_rate <= 0.00001f ? 0.0f : clamp01(temporal_position - static_cast(time0)); const float smooth_temporal_mix = temporal_mix * temporal_mix * (3.0f - 2.0f * temporal_mix); - const uint32_t base_seed = static_cast(seed) ^ mix32(hash_string(Id())); - const float intensity_scale = amount_value * 0.18f; - const bool use_temporal_blend = temporal_rate > 0.00001f && smooth_temporal_mix > 0.00001f; - const bool use_softness = softness_value > 0.00001f; - const bool use_clump = clump_value > 0.00001f; - const bool use_color = color_amount_value > 0.00001f; - const bool use_color_variation = use_color && color_variation_value > 0.00001f; + const uint32_t base_seed = static_cast(seed) ^ mix32(hash_string(Id())); + const float intensity_scale = amount_value * 0.18f; + const bool use_temporal_blend = temporal_rate > 0.00001f && smooth_temporal_mix > 0.00001f; + const bool use_softness = softness_value > 0.00001f; + const bool use_clump = clump_value > 0.00001f; + const bool use_color = color_amount_value > 0.00001f; + const bool use_color_variation = use_color && color_variation_value > 0.00001f; + + // How many temporal samples and how many channel salts to compute + const int num_times = use_temporal_blend ? 2 : 1; + const int num_salts = use_color_variation ? 4 : 1; + const int time_buckets[2] = { time0, time1 }; + const uint32_t base_salts[4] = { kSaltLuma, kSaltRed, kSaltGreen, kSaltBlue }; static const std::array inv_alpha = [] { std::array lut{}; @@ -172,11 +168,11 @@ std::shared_ptr FilmGrain::GetFrame(std::shared_ptr(frame_image->bits()); - const int width = frame_image->width(); + const int width = frame_image->width(); const int height = frame_image->height(); const int stride = frame_image->bytesPerLine(); const int pixel_count = width * height; - int reference_width = width; + int reference_width = width; int reference_height = height; Clip* clip = static_cast(ParentClip()); @@ -186,34 +182,73 @@ std::shared_ptr FilmGrain::GetFrame(std::shared_ptr(ParentTimeline()); if (timeline && timeline->info.width > 0 && timeline->info.height > 0) { - reference_width = timeline->info.width; + reference_width = timeline->info.width; reference_height = timeline->info.height; } - const float reference_scale_x = width > 0 ? static_cast(reference_width) / static_cast(width) : 1.0f; + const float reference_scale_x = width > 0 ? static_cast(reference_width) / static_cast(width) : 1.0f; const float reference_scale_y = height > 0 ? static_cast(reference_height) / static_cast(height) : 1.0f; #pragma omp parallel for if(pixel_count >= 16384) schedule(static) for (int y = 0; y < height; ++y) { unsigned char* row = pixels + (y * stride); + + // Pre-compute all y-dependent values once per row (not per pixel). + // reference_x/y coords are always non-negative, so (int) cast is safe instead of floor. + const float reference_y = static_cast(y) * reference_scale_y; + const int fine_iy = static_cast(reference_y * fine_frequency); + + // Soft noise: y-fractional components are constant across the row + int soft_iy0 = 0; + float soft_sy_fac = 0.0f; // smoothstep of the y fractional part + if (use_softness) { + const float sry = reference_y * soft_frequency; + soft_iy0 = static_cast(sry); + const float t = sry - static_cast(soft_iy0); + soft_sy_fac = t * t * (3.0f - 2.0f * t); + } + + // Clump noise: same idea + int clump_iy0 = 0; + float clump_sy_fac = 0.0f; + if (use_clump) { + const float cry = reference_y * clump_frequency; + clump_iy0 = static_cast(cry); + const float t = cry - static_cast(clump_iy0); + clump_sy_fac = t * t * (3.0f - 2.0f * t); + } + + // Per-row x-cell caches. Initialized to -1 so the first pixel always + // triggers a fill. All computed cell indices are >= 0, so -1 is a safe sentinel. + int cached_fine_ix = -1; + int cached_soft_ix0 = -1; + int cached_clump_ix0 = -1; + + // fine_cache[time_idx][salt_idx]: direct hash value per fine-grid cell + float fine_cache[2][4] = {}; + + // soft_col0/col1[time_idx][salt_idx]: value_noise y-axis columns pre-lerped. + // Per pixel we only need one lerp across x instead of 4 hash calls + 3 lerps. + float soft_col0[2][4] = {}, soft_col1[2][4] = {}; + + // clump_col0/col1[salt_idx]: clump only uses time0 + float clump_col0[4] = {}, clump_col1[4] = {}; + for (int x = 0; x < width; ++x) { const int idx = x * 4; - const int A = row[idx + 3]; + const int A = row[idx + 3]; if (A <= 0) continue; - float R = 0.0f; - float G = 0.0f; - float B = 0.0f; - const float alpha_percent = static_cast(A) * kInv255; + float R, G, B; if (A == 255) { R = row[idx + 0] * kInv255; G = row[idx + 1] * kInv255; B = row[idx + 2] * kInv255; } else { - const float inv_alpha_percent = inv_alpha[A]; - R = (row[idx + 0] * inv_alpha_percent) * kInv255; - G = (row[idx + 1] * inv_alpha_percent) * kInv255; - B = (row[idx + 2] * inv_alpha_percent) * kInv255; + const float inv_a = inv_alpha[A]; + R = (row[idx + 0] * inv_a) * kInv255; + G = (row[idx + 1] * inv_a) * kInv255; + B = (row[idx + 2] * inv_a) * kInv255; } const float luma = (0.299f * R) + (0.587f * G) + (0.114f * B); @@ -222,48 +257,101 @@ std::shared_ptr FilmGrain::GetFrame(std::shared_ptr(x) * reference_scale_x; - const float reference_y = static_cast(y) * reference_scale_y; - const float fx = reference_x * fine_frequency; - const float fy = reference_y * fine_frequency; - const float sx = reference_x * soft_frequency; - const float sy = reference_y * soft_frequency; - const float cx = reference_x * clump_frequency; - const float cy = reference_y * clump_frequency; - - const int ix = static_cast(std::floor(fx)); - const int iy = static_cast(std::floor(fy)); - const auto grain_at_time = [&](uint32_t salt, int time_bucket) { - const float fine = hash_signed(base_seed, ix, iy, time_bucket, salt); - if (!use_softness) - return fine; - return lerp(fine, value_noise(base_seed, sx, sy, time_bucket, salt ^ 0x51633e2du), softness_value); - }; - const auto grain_sample = [&](uint32_t salt) { - float grain = grain_at_time(salt, time0); - if (use_temporal_blend) - grain = lerp(grain, grain_at_time(salt, time1), smooth_temporal_mix); + + // Fine grain: one hash per (cell, time, salt). Update when the x-cell changes. + const int fine_ix = static_cast(reference_x * fine_frequency); + if (fine_ix != cached_fine_ix) { + cached_fine_ix = fine_ix; + for (int t = 0; t < num_times; ++t) + for (int s = 0; s < num_salts; ++s) + fine_cache[t][s] = hash_signed(base_seed, fine_ix, fine_iy, time_buckets[t], base_salts[s]); + } + + // Soft noise: bilinear value_noise, but y-axis columns are pre-lerped into + // soft_col0/col1. Only 4 hashes needed when the x-cell changes (~every 7 px), + // then 1 lerp per pixel instead of 4 hashes + 3 lerps every pixel. + float soft_sx = 0.0f; + if (use_softness) { + const float srx = reference_x * soft_frequency; + const int soft_ix0 = static_cast(srx); + if (soft_ix0 != cached_soft_ix0) { + cached_soft_ix0 = soft_ix0; + const int soft_ix1 = soft_ix0 + 1; + const int soft_iy1 = soft_iy0 + 1; + for (int t = 0; t < num_times; ++t) { + for (int s = 0; s < num_salts; ++s) { + const uint32_t salt = base_salts[s] ^ kSoftXor; + const float n00 = hash_signed(base_seed, soft_ix0, soft_iy0, time_buckets[t], salt); + const float n10 = hash_signed(base_seed, soft_ix1, soft_iy0, time_buckets[t], salt); + const float n01 = hash_signed(base_seed, soft_ix0, soft_iy1, time_buckets[t], salt); + const float n11 = hash_signed(base_seed, soft_ix1, soft_iy1, time_buckets[t], salt); + // y-interpolate left and right columns separately; x-interpolation is per-pixel + soft_col0[t][s] = n00 + (n01 - n00) * soft_sy_fac; + soft_col1[t][s] = n10 + (n11 - n10) * soft_sy_fac; + } + } + } + const float tx = srx - static_cast(cached_soft_ix0); + soft_sx = tx * tx * (3.0f - 2.0f * tx); + } + + // Clump noise: same column-caching strategy, only time0 + float clump_sx = 0.0f; + if (use_clump) { + const float crx = reference_x * clump_frequency; + const int clump_ix0 = static_cast(crx); + if (clump_ix0 != cached_clump_ix0) { + cached_clump_ix0 = clump_ix0; + const int clump_ix1 = clump_ix0 + 1; + const int clump_iy1 = clump_iy0 + 1; + for (int s = 0; s < num_salts; ++s) { + const uint32_t salt = base_salts[s] ^ kClumpXor; + const float n00 = hash_signed(base_seed, clump_ix0, clump_iy0, time0, salt); + const float n10 = hash_signed(base_seed, clump_ix1, clump_iy0, time0, salt); + const float n01 = hash_signed(base_seed, clump_ix0, clump_iy1, time0, salt); + const float n11 = hash_signed(base_seed, clump_ix1, clump_iy1, time0, salt); + clump_col0[s] = n00 + (n01 - n00) * clump_sy_fac; + clump_col1[s] = n10 + (n11 - n10) * clump_sy_fac; + } + } + const float tx = crx - static_cast(cached_clump_ix0); + clump_sx = tx * tx * (3.0f - 2.0f * tx); + } + + float grains[4]; + for (int s = 0; s < num_salts; ++s) { + float g0 = fine_cache[0][s]; + if (use_softness) { + const float soft0 = soft_col0[0][s] + (soft_col1[0][s] - soft_col0[0][s]) * soft_sx; + g0 += (soft0 - g0) * softness_value; + } + float grain; + if (use_temporal_blend) { + float g1 = fine_cache[1][s]; + if (use_softness) { + const float soft1 = soft_col0[1][s] + (soft_col1[1][s] - soft_col0[1][s]) * soft_sx; + g1 += (soft1 - g1) * softness_value; + } + grain = g0 + (g1 - g0) * smooth_temporal_mix; + } else { + grain = g0; + } if (use_clump) { - const float cluster = value_noise(base_seed, cx, cy, time0, salt ^ 0xa511e9b3u); + const float cluster = clump_col0[s] + (clump_col1[s] - clump_col0[s]) * clump_sx; grain *= lerp(1.0f, 0.45f + (std::abs(cluster) * 1.35f), clump_value); } - return grain; - }; - - const float luma_grain = grain_sample(0x1000193u); - float red_grain = luma_grain; - float green_grain = luma_grain; - float blue_grain = luma_grain; - if (use_color_variation) { - red_grain = lerp(luma_grain, grain_sample(0x8da6b343u), color_variation_value); - green_grain = lerp(luma_grain, grain_sample(0xd8163841u), color_variation_value); - blue_grain = lerp(luma_grain, grain_sample(0xcb1ab31fu), color_variation_value); + grains[s] = grain; } - const float strength = intensity_scale * tone; - if (use_color) { - R = clamp01(R + (lerp(luma_grain, red_grain, color_amount_value) * strength)); - G = clamp01(G + (lerp(luma_grain, green_grain, color_amount_value) * strength)); - B = clamp01(B + (lerp(luma_grain, blue_grain, color_amount_value) * strength)); + const float strength = intensity_scale * tone; + const float luma_grain = grains[0]; + if (use_color_variation) { + const float red_grain = luma_grain + (grains[1] - luma_grain) * color_variation_value; + const float green_grain = luma_grain + (grains[2] - luma_grain) * color_variation_value; + const float blue_grain = luma_grain + (grains[3] - luma_grain) * color_variation_value; + R = clamp01(R + (luma_grain + (red_grain - luma_grain) * color_amount_value) * strength); + G = clamp01(G + (luma_grain + (green_grain - luma_grain) * color_amount_value) * strength); + B = clamp01(B + (luma_grain + (blue_grain - luma_grain) * color_amount_value) * strength); } else { const float delta = luma_grain * strength; R = clamp01(R + delta); @@ -276,6 +364,7 @@ std::shared_ptr FilmGrain::GetFrame(std::shared_ptr(clampByte(G * 255.0f)); row[idx + 2] = static_cast(clampByte(B * 255.0f)); } else { + const float alpha_percent = static_cast(A) * kInv255; row[idx + 0] = static_cast(clampByte(R * 255.0f * alpha_percent)); row[idx + 1] = static_cast(clampByte(G * 255.0f * alpha_percent)); row[idx + 2] = static_cast(clampByte(B * 255.0f * alpha_percent)); @@ -292,19 +381,19 @@ std::string FilmGrain::Json() const { Json::Value FilmGrain::JsonValue() const { Json::Value root = EffectBase::JsonValue(); - root["type"] = info.class_name; - root["amount"] = amount.JsonValue(); - root["size"] = size.JsonValue(); - root["softness"] = softness.JsonValue(); - root["clump"] = clump.JsonValue(); - root["shadows"] = shadows.JsonValue(); - root["midtones"] = midtones.JsonValue(); - root["highlights"] = highlights.JsonValue(); - root["color_amount"] = color_amount.JsonValue(); + root["type"] = info.class_name; + root["amount"] = amount.JsonValue(); + root["size"] = size.JsonValue(); + root["softness"] = softness.JsonValue(); + root["clump"] = clump.JsonValue(); + root["shadows"] = shadows.JsonValue(); + root["midtones"] = midtones.JsonValue(); + root["highlights"] = highlights.JsonValue(); + root["color_amount"] = color_amount.JsonValue(); root["color_variation"] = color_variation.JsonValue(); - root["evolution"] = evolution.JsonValue(); - root["coherence"] = coherence.JsonValue(); - root["seed"] = seed; + root["evolution"] = evolution.JsonValue(); + root["coherence"] = coherence.JsonValue(); + root["seed"] = seed; return root; } @@ -319,45 +408,33 @@ void FilmGrain::SetJson(const std::string value) { void FilmGrain::SetJsonValue(const Json::Value root) { EffectBase::SetJsonValue(root); - if (!root["amount"].isNull()) - amount.SetJsonValue(root["amount"]); - if (!root["size"].isNull()) - size.SetJsonValue(root["size"]); - if (!root["softness"].isNull()) - softness.SetJsonValue(root["softness"]); - if (!root["clump"].isNull()) - clump.SetJsonValue(root["clump"]); - if (!root["shadows"].isNull()) - shadows.SetJsonValue(root["shadows"]); - if (!root["midtones"].isNull()) - midtones.SetJsonValue(root["midtones"]); - if (!root["highlights"].isNull()) - highlights.SetJsonValue(root["highlights"]); - if (!root["color_amount"].isNull()) - color_amount.SetJsonValue(root["color_amount"]); - if (!root["color_variation"].isNull()) - color_variation.SetJsonValue(root["color_variation"]); - if (!root["evolution"].isNull()) - evolution.SetJsonValue(root["evolution"]); - if (!root["coherence"].isNull()) - coherence.SetJsonValue(root["coherence"]); - if (!root["seed"].isNull()) - seed = root["seed"].asInt(); + if (!root["amount"].isNull()) amount.SetJsonValue(root["amount"]); + if (!root["size"].isNull()) size.SetJsonValue(root["size"]); + if (!root["softness"].isNull()) softness.SetJsonValue(root["softness"]); + if (!root["clump"].isNull()) clump.SetJsonValue(root["clump"]); + if (!root["shadows"].isNull()) shadows.SetJsonValue(root["shadows"]); + if (!root["midtones"].isNull()) midtones.SetJsonValue(root["midtones"]); + if (!root["highlights"].isNull()) highlights.SetJsonValue(root["highlights"]); + if (!root["color_amount"].isNull()) color_amount.SetJsonValue(root["color_amount"]); + if (!root["color_variation"].isNull()) color_variation.SetJsonValue(root["color_variation"]); + if (!root["evolution"].isNull()) evolution.SetJsonValue(root["evolution"]); + if (!root["coherence"].isNull()) coherence.SetJsonValue(root["coherence"]); + if (!root["seed"].isNull()) seed = root["seed"].asInt(); } std::string FilmGrain::PropertiesJSON(int64_t requested_frame) const { Json::Value root = BasePropertiesJSON(requested_frame); - root["amount"] = add_property_json("Amount", amount.GetValue(requested_frame), "float", "Overall grain intensity.", &amount, 0.0, 1.0, false, requested_frame); - root["size"] = add_property_json("Size", size.GetValue(requested_frame), "float", "Fine to coarse grain scale.", &size, 0.0, 1.0, false, requested_frame); - root["softness"] = add_property_json("Softness", softness.GetValue(requested_frame), "float", "Hard crisp grain to softer organic grain.", &softness, 0.0, 1.0, false, requested_frame); - root["clump"] = add_property_json("Clump", clump.GetValue(requested_frame), "float", "Even grain to clustered irregular grain.", &clump, 0.0, 1.0, false, requested_frame); - root["shadows"] = add_property_json("Shadows", shadows.GetValue(requested_frame), "float", "Grain strength in dark regions.", &shadows, 0.0, 1.0, false, requested_frame); - root["midtones"] = add_property_json("Midtones", midtones.GetValue(requested_frame), "float", "Grain strength in middle tonal regions.", &midtones, 0.0, 1.0, false, requested_frame); - root["highlights"] = add_property_json("Highlights", highlights.GetValue(requested_frame), "float", "Grain strength in bright regions.", &highlights, 0.0, 1.0, false, requested_frame); - root["color_amount"] = add_property_json("Color Amount", color_amount.GetValue(requested_frame), "float", "How much grain affects chroma instead of mostly luma.", &color_amount, 0.0, 1.0, false, requested_frame); - root["color_variation"] = add_property_json("Color Variation", color_variation.GetValue(requested_frame), "float", "Correlated to independently varied color grain.", &color_variation, 0.0, 1.0, false, requested_frame); - root["evolution"] = add_property_json("Evolution", evolution.GetValue(requested_frame), "float", "How much grain renews over time.", &evolution, 0.0, 1.0, false, requested_frame); - root["coherence"] = add_property_json("Coherence", coherence.GetValue(requested_frame), "float", "How stable and smooth grain remains between frames.", &coherence, 0.0, 1.0, false, requested_frame); - root["seed"] = add_property_json("Seed", seed, "int", "Deterministic grain variation.", NULL, 0, 1000000, false, requested_frame); + root["amount"] = add_property_json("Amount", amount.GetValue(requested_frame), "float", "Overall grain intensity.", &amount, 0.0, 1.0, false, requested_frame); + root["size"] = add_property_json("Size", size.GetValue(requested_frame), "float", "Fine to coarse grain scale.", &size, 0.0, 1.0, false, requested_frame); + root["softness"] = add_property_json("Softness", softness.GetValue(requested_frame), "float", "Hard crisp grain to softer organic grain.", &softness, 0.0, 1.0, false, requested_frame); + root["clump"] = add_property_json("Clump", clump.GetValue(requested_frame), "float", "Even grain to clustered irregular grain.", &clump, 0.0, 1.0, false, requested_frame); + root["shadows"] = add_property_json("Shadows", shadows.GetValue(requested_frame), "float", "Grain strength in dark regions.", &shadows, 0.0, 1.0, false, requested_frame); + root["midtones"] = add_property_json("Midtones", midtones.GetValue(requested_frame), "float", "Grain strength in middle tonal regions.", &midtones, 0.0, 1.0, false, requested_frame); + root["highlights"] = add_property_json("Highlights", highlights.GetValue(requested_frame), "float", "Grain strength in bright regions.", &highlights, 0.0, 1.0, false, requested_frame); + root["color_amount"] = add_property_json("Color Amount", color_amount.GetValue(requested_frame), "float", "How much grain affects chroma instead of mostly luma.", &color_amount, 0.0, 1.0, false, requested_frame); + root["color_variation"] = add_property_json("Color Variation", color_variation.GetValue(requested_frame), "float", "Correlated to independently varied color grain.", &color_variation, 0.0, 1.0, false, requested_frame); + root["evolution"] = add_property_json("Evolution", evolution.GetValue(requested_frame), "float", "How much grain renews over time.", &evolution, 0.0, 1.0, false, requested_frame); + root["coherence"] = add_property_json("Coherence", coherence.GetValue(requested_frame), "float", "How stable and smooth grain remains between frames.", &coherence, 0.0, 1.0, false, requested_frame); + root["seed"] = add_property_json("Seed", seed, "int", "Deterministic grain variation.", NULL, 0, 1000000, false, requested_frame); return root.toStyledString(); }