From c2d64dcd6d9dd1579c3288fbd95239470e1314a7 Mon Sep 17 00:00:00 2001 From: gramps Date: Sat, 10 Oct 2026 07:08:01 -0700 Subject: [PATCH] TASK 51 blur 'heavy' fix: quadratic blur radius. BlurRadius(strength) = 1 + 5s^2, MaxBlurRadius 12 -> 6 downscaled px (~24 full px max). Default 0.5 is now radius 2 small px (was the old linear 6) - ~3x lighter perceived spread, matching the creator's real-camera finding. Range from the established tools: StreamFX defaults its Box blur Size to 5 and its author stops downscale-based blurs around 8px. One integration test pins the mapping (0 -> 1, 0.5 -> 2, 1 -> 6) and step-edge behaviour (light reaches 9px at default vs 26px at full); the existing slice-2 blur test passes unchanged. 419/419 pass. Refs: https://github.com/Vhonowslend/StreamFX-Public/wiki/Filter-Blur https://xaymar.com/articles/2020/04/26/optimizing-streamfx-in-your-obs-scenes/ (Unit: bg-blur heavy fix) --- HANDOFF.md | 44 ++++++------- .../BackgroundEffectProcessor.cs | 18 +++++- Services/index.md | 2 +- TASKS.md | 2 +- TASKS/task-51-webcam-bg-effects.md | 22 ++++--- ai.md | 19 ++++-- ytLive.Tests/BackgroundEffectEngineTests.cs | 61 +++++++++++++++++++ 7 files changed, 124 insertions(+), 44 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 644d594..f7f6321 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,4 +1,4 @@ -# HANDOFF — 2026-10-09 (TASK 51 quality pass: jitter FIXED; blur-heavy is the next unit) +# HANDOFF — 2026-10-09 (TASK 51 quality pass COMPLETE: jitter + blur-heavy both fixed) ## Branch - main (no feature branch). Pushed through `5685c5d` (TRAX toggle); everything after is local, @@ -17,31 +17,25 @@ the not-mine files WILL flag. Pass them to the script as declared-known (checksums unchanged since `5685c5d`) or let the script's output be reconciled before committing. -## What just happened — temporal jitter fix -- Creator check: bg Remove is "jittery", Blur is "heavy". User picked **jitter first**. -- Root cause: `OnnxPersonSegmenter` classifies each frame independently (256×144 mask, bilinear - upsample) → zero temporal coherence → edge flutter. There is no temporal smoothing upstream. -- **Fix: `MaskSmoother`** — per-session blend of each raw mask with the previous smoothed one, - 0.75×new + 0.25×previous (`CurrentWeight`, internal). Wired at the ONE production point - (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`); state on - `CameraSession`, NOT the segmenter (segmenter is shared app-wide and contract-pinned to raw output). - Null mask resets history; first frame/size-change passthrough; never aliases the caller's array; two - ping-pong buffers, no per-frame LOH alloc. Weights: OBS obs-backgroundremoval 0.85 default test, Jitsi - jitsi/jitsi-meet#17080 0.7/0.3 best (0.5/0.5 smears on motion). -- **Test:** `CameraManagerTests.Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames` — PASSED. - A `SteppingMaskSegmenter` returns fresh masks per call: `{1,1,0,0}` then `{1,0.2,0,0}`; 16×4 frame, - Remove 0.5. Asserts x=6: frame1 = 255 (passthrough), frame2 smoothed = **58**, raw-unsmoothed = 0; - person core x=1 = 255, background x=14 = 0. ⚠️ Gotcha: the worker's latest-frame-drop guard treats a - re-queued IDENTICAL frame reference as "no new frame" — pump frame2 as a NEW `VideoFrame` instance. -- `Assert.Equal(255, first[...])`/`58`/`0` predictions were hand-verified; other pixels differ by - float rounding (e.g. x=5 Python 34 vs C# 36) — only assert x=6/x=1/x=14. +## What just happened — two commits +1. **Jitter fix (`3057069`) — `MaskSmoother`**: per-session blend 0.75×new + 0.25×previous at the + ONE worker (`CameraManager.RunEffectWorker` → `session.Smoother.Smooth(TrySegment(source))`). + State on `CameraSession`, NOT the segmenter (shared app-wide, contract-pinned to raw output). Null + resets history; first frame passes through; never aliases caller's array; ping-pong buffers (no + per-frame LOH alloc). Weights: OBS obs-backgroundremoval 0.85 default, Jitsi 0.7/0.3 best + (0.5/0.5 smears). Test `Temporal_Smoothing_Damps_Segmentation_Jitter_Across_Frames`: x=6 → first + 255 / smoothed 58 / raw-unsmoothed 0. ⚠️ Gotcha: worker guards a re-queued identical frame + reference — pump frame2 as a NEW instance. +2. **Blur "heavy" fix (this commit) — quadratic radius**: `BlurRadius(strength)` = 1 + 5·s², + `MaxBlurRadius` 12→6 small px (≈ 24 full px max). Default 0.5 = 2 small px (was 6 under the old + linear curve) — ~3× lighter spread. Rationale: StreamFX defaults Box blur Size to 5, author stops + downscaled blurs ~8px. Test `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the + mapping and step-edge behaviour (reach 9px default vs 26px max). Point-source probes vanish below + the 0.5 rounding (3 box passes dilute a spike) — use a step edge, not a spike, to measure blur. ## Next step -1. Run `./scripts/verify.sh` with the declared file list; reconcile the not-mine dirty files with - scope-check; commit the unit (message `…(Unit: bg-removal jitter fix)` + OBS/Jitsi citations). -2. Next unit = **Blur "heavy"** — soften the radius curve in `BackgroundEffectProcessor.ApplyBlur` - (`MinBlurRadius=1 / MaxBlurRadius=12` at ¼ res ≈ 26–50 full px; strength 0.5 → 6 small px). The - creator needs a retry pass on the real camera after both. +- Creator re-runs the app: webcam → Layers properties → Background → Remove / Blur + Strength, and + reports whether the jitter is gone and the blur feels right at the slider's default and full range. ## Also still open - **Vertical-format regression (STALE, not fixed):** 1080p60 vertical selection no longer shrinks the @@ -53,7 +47,7 @@ - **Vertical recording file closed:** the recording itself is 1080×1920 60fps correctly; the "whitespace" was the app's preview UI, not the file. Audio loudness (`ebur128`) for the private video was never run and the thread is closed unless the user reopens it. -- Blur-heaviness = next unit (above). Yellow-border regression = stale thread. +- Blur-heaviness = fixed (above). Yellow-border regression = stale thread. - ⛔ Sudo password rotation pending (user; then update both password lines in `CREDENTIALS.md`). - TASK 56 secret scan before `gramps/LlamaCasty` goes public. TASK 55 disk hygiene queued. diff --git a/Services/BackgroundEffects/BackgroundEffectProcessor.cs b/Services/BackgroundEffects/BackgroundEffectProcessor.cs index 09e77d6..1a9355d 100644 --- a/Services/BackgroundEffects/BackgroundEffectProcessor.cs +++ b/Services/BackgroundEffects/BackgroundEffectProcessor.cs @@ -32,9 +32,12 @@ public sealed class BackgroundEffectProcessor /// upsampled while blending — a strong blur at a fraction of the cost. internal const int BlurDownscale = 4; - /// Box-blur radius (in downscaled pixels) at strength 0 and 1. + /// Box-blur radius (in downscaled pixels) at strength 0 and 1. The maximum was halved + /// from 12 (2026-10-09): the creator found full range too heavy — the old max spread ≈48 full + /// px. The established tools blur far less: StreamFX defaults its Box blur size to 5 and its + /// author stops downscale-based blurs around 8 px. internal const int MinBlurRadius = 1; - internal const int MaxBlurRadius = 12; + internal const int MaxBlurRadius = 6; private readonly IPersonSegmenter _segmenter; private bool _loggedFailure; @@ -161,7 +164,7 @@ public sealed class BackgroundEffectProcessor }); // 2. Three separable box-blur passes ≈ a gaussian. - var radius = (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * strength); + var radius = BlurRadius(strength); var scratch = new float[small.Length]; for (var pass = 0; pass < 3; pass++) { @@ -212,6 +215,15 @@ public sealed class BackgroundEffectProcessor return new VideoFrame(width, height, dst) { Epoch = frame.Epoch, IsOpaque = frame.IsOpaque }; } + /// Blur radius in downscaled pixels for a strength. Quadratic (s²), not linear, so the + /// default 0.5 stays gentle (2 small px ≈ ~8–10 full px of perceived spread, ~3× lighter than + /// the old linear 6) while only the top of the slider approaches the full range. + internal static int BlurRadius(double strength) + { + var s = Math.Clamp(strength, 0, 1); + return (int)Math.Round(MinBlurRadius + (MaxBlurRadius - MinBlurRadius) * s * s); + } + /// Row-parallel pixel loops, capped so the effect worker can't starve the compositor /// and encoder threads. private static readonly ParallelOptions RowParallelism = new() diff --git a/Services/index.md b/Services/index.md index 3ddeb6f..cc74c2f 100644 --- a/Services/index.md +++ b/Services/index.md @@ -20,7 +20,7 @@ External-facing logic: YouTube API, persistence. See | `BackgroundEffects/IPersonSegmenter.cs` | **TASK 51 seam:** `Segment(VideoFrame) → SegmentationMask?` (`SegmentationMask(Width, Height, float[] Values)`, 0 = background, 1 = person); null = no mask → caller passes through. Tests inject fakes | | `BackgroundEffects/OnnxPersonSegmenter.cs` | **TASK 51 default segmenter:** MediaPipe Selfie Segmenter (landscape, Apache-2.0) as an embedded ONNX resource, run by onnxruntime on the CPU (2 intra-op threads); frame stretched to 256×144 RGB/255 NHWC; lazy session; never throws (fail → log once, null forever) | | `BackgroundEffects/WebcamEffectKey.cs` | **TASK 51:** `(Effect, Strength)` variant key, strength rounded to 0.01; `None` normalizes to strength 0 (the raw path) | -| `BackgroundEffects/BackgroundEffectProcessor.cs` | **TASK 51 pure pixel stage:** `Apply(frame, BackgroundEffect)` — None = same instance, Remove = copy with alpha = feathered mask × source alpha (`IsOpaque=false`), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; `TrySegment` once + `Render` per effect; no mask/exception → passthrough + one log | +| `BackgroundEffects/BackgroundEffectProcessor.cs` | **TASK 51 pure pixel stage:** `Apply(frame, BackgroundEffect)` — None = same instance, Remove = copy with alpha = feathered mask × source alpha (`IsOpaque=false`), Blur = background blurred at ¼ res (3 box passes) and mask-blended, person untouched; strength 0..1; blur radius is **quadratic** via `BlurRadius(strength)` — 1→6 small px (default 0.5 = 2, was a linear 1→12 ≈ 48 full px; the "blur heavy" fix 2026-10-09); `TrySegment` once + `Render` per effect; no mask/exception → passthrough + one log | | `BackgroundEffects/MaskSmoother.cs` | **TASK 51 jitter fix (2026-10-09):** temporally blends each raw mask with the previous smoothed one — `CurrentWeight` 0.75 × new + 0.25 × previous — so a per-frame segmentation verdict that wavers at an edge doesn't flutter the cut-out. Null input resets history; first frame/size change pass through; the returned mask never aliases the caller's array and the two buffers ping-pong (no per-frame LOH alloc: a 256×144 mask is 147 KB). State is **per camera session**, not in the segmenter (which is shared app-wide and contract-pinned to raw output). Weights from the OBS/Jitsi consensus — see `ai.md` "Webcam background effects" | | `CameraDeviceInfo.cs` | `(Id, DisplayName)` for a physical capture device | | `ICameraEnumerator.cs` | `GetCamerasAsync()` — seam so the picker/`CameraManager` never touch WinRT (tests inject fakes) | diff --git a/TASKS.md b/TASKS.md index c99f640..6129040 100644 --- a/TASKS.md +++ b/TASKS.md @@ -66,7 +66,7 @@ | 48 | Distribution & packaging: **MSIX + Store IAP** | 🔶 In progress — **✅ route decided 2026-09-27 (Store MSIX + Store IAP)**; **item 1 (bundle ffmpeg) SHIPPED 2026-09-28** | [`TASKS/task-48-distribution-msix.md`](TASKS/task-48-distribution-msix.md) | | 49 | Chat profanity filter (local, opt-in, non-persistent, user word list) | ☐ Queued (2026-09-27) | [`TASKS/task-49-chat-profanity-filter.md`](TASKS/task-49-chat-profanity-filter.md) | | 50 | One-button "Publish this video" (resumable upload of the local recording) | ☐ Queued (2026-09-28) | [`TASKS/task-50-publish-recording.md`](TASKS/task-50-publish-recording.md) | -| 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Creator check 2026-10-09: **Remove jitter FIXED** (temporal mask smoothing 0.75/0.25, OBS/Jitsi-derived); **Blur "heavy" = next work unit** (radius curve) | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) | +| 51 | **Webcam background effects: Remove + Blur** | ✅ **Built 2026-10-09** (v1 requirement) — bundled MediaPipe Selfie Segmenter → ONNX (verified vs Google's runtime), Remove + Blur with strength, per-scene config persisted, one processing point in CameraManager, radio-pill UI in the webcam's Layers properties. Creator check 2026-10-09: **Remove jitter FIXED** (temporal mask smoothing 0.75/0.25, OBS/Jitsi-derived) and **Blur "heavy" FIXED** (quadratic radius 1→6 small px, default 0.5 = 2, StreamFX-consensus); recheck on the real camera pending | [`TASKS/task-51-webcam-bg-effects.md`](TASKS/task-51-webcam-bg-effects.md) | | 52 | **Zoom box (anchored 2× magnifier)** | ✅ Done — shipped 2026-10-08 (3 creator corrections during the build: loupe → anchored capture over the full composite, drawn last → never shows the dim shading) | [`TASKS/task-52-zoom-box.md`](TASKS/task-52-zoom-box.md) | | 53 | **Output-format verification: Shorts/TikTok geometry (9:16)** | ☐ Queued (2026-10-08) — output formats never tested end-to-end; pin + prove the vertical geometry | [`TASKS/task-53-output-formats.md`](TASKS/task-53-output-formats.md) | | 54 | **Marcom round: free-forever positioning + merch storefront** | 🔶 In progress — llamacasty.com copy live 2026-10-08 (pricing stripped); merch POD + app About links queued | [`TASKS/task-54-marcom-free-merch.md`](TASKS/task-54-marcom-free-merch.md) | diff --git a/TASKS/task-51-webcam-bg-effects.md b/TASKS/task-51-webcam-bg-effects.md index 57bad12..44d9b56 100644 --- a/TASKS/task-51-webcam-bg-effects.md +++ b/TASKS/task-51-webcam-bg-effects.md @@ -5,10 +5,11 @@ > This **reverses** the 2026-10-05 "background removal: out of product" exclusion (TASK 3 item 19). **Status:** ✅ **Built 2026-10-09 — all five slices shipped** (engine, Blur, config + persistence, -camera hookup, UI). **Creator's real-camera check done 2026-10-09:** 🎯 **Remove "jittery" — FIXED** -(temporal mask smoothing, `MaskSmoother` — 0.75×new + 0.25×previous momentum, weights from the -OBS/Jitsi consensus); 🔶 **Blur "heavy" — the remaining follow-up** (next work unit, on the radius -curve in `BackgroundEffectProcessor.ApplyBlur`; documented below). +camera hookup, UI). **Creator's real-camera check done 2026-10-09 and both findings addressed:** +🎯 **Remove "jittery" — FIXED** (temporal mask smoothing, `MaskSmoother` — 0.75×new + 0.25×previous +momentum, weights from the OBS/Jitsi consensus); 🎯 **Blur "heavy" — FIXED** (quadratic radius curve, +max 12→6 small px, default 0.5 = 2 — StreamFX-consensus scale). **Pending: creator's recheck of both +on a real camera.** **Goal:** per-webcam **Background Remove** and **Background Blur** on the live webcam layer, selected per element in its properties panel, composited into stream + recording like any @@ -123,10 +124,15 @@ delivery shape changed: resets history; first frame passes through; no per-frame allocation (two ping-pong buffers — a 256×144 mask is 147 KB > the 85 KB LOH). Integration test: `CameraManagerTests.Temporal_Smoothing_…` (two consecutive masks disagree at an edge → second frame keeps partial alpha where raw would cut). -- 🔶 **Finding: "blur is heavy"** — strength 0.5 ≈ radius 6 downscaled px ≈ **26 full-res px**, max - ≈ 50 px (1+11·s at ¼ res, 3 box passes). **Next work unit:** soften the radius curve/range - (e.g. lower the strength→radius mapping, and/or a shorter maximum) — see - `BackgroundEffectProcessor` `MinBlurRadius`/`MaxBlurRadius`/`BlurDownscale`. +- 🎯 **Finding: "blur is heavy"** — the OLD curve was linear 1→12 downscaled px (≈ 26–50 full-res px), + far past any moderate background blur. **Fix shipped: quadratic radius** — + `BackgroundEffectProcessor.BlurRadius(strength)` = 1 + 5·s²: default 0.5 = 2 small px (≈ a third of + the old default's spread), max = 6 small px (≈ 24 full px). Why these numbers: StreamFX defaults its + Box blur Size to 5 and its author stops downscale-based blurs around 8 px (cited). Integration test + `BackgroundEffectEngineTests.Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the + mapping (BlurRadius(0)=1, 0.5→2, 1→6) and the step-edge behaviour: default smears light ~9px into + the dark half vs ~26px at full strength. The existing slice-2 blur test (person sharp + checkerboard + smoothed) passes unchanged. - ✅ `startup.log` perf line present on the real box ("background effects avg … ms/frame"). ## Related (not duplicated here) diff --git a/ai.md b/ai.md index 1fca885..c30c89a 100644 --- a/ai.md +++ b/ai.md @@ -636,11 +636,14 @@ This replaces the old five-seeder cluster (`Seed{Starting,Brb,Ending,Chat}Backgr passthrough), `Remove` = copy with alpha = bilinear-upscaled mask × source alpha, RGB untouched, `IsOpaque = false` (the compositor's straight-alpha blend shows the layers below), strength = edge feather (mask band 0.5±(0.05+0.45·s) → alpha); `Blur` (slice 2) = background downscaled 4× - by box average, 3 separable box-blur passes (radius 1→12 small px by strength ≈ gaussian, up to - ~48 full px), bilinear-upsampled and blended by the mask — person pixels (mask = 1) are copied - untouched, opacity preserved. The mask is computed once per frame (`TrySegment`) and rendered per - effect (`Render`), so several variants share one inference. No mask / segmenter exception → - passthrough + one log line, never a dropped layer. + by box average, 3 separable box-blur passes and bilinear-upsampled, blended by the mask — person + pixels (mask = 1) are copied + untouched, opacity preserved. **Blur radius is quadratic** (`BlurRadius(strength)`: 1→6 small px, + default 0.5 = 2 — the "blur heavy" fix 2026-10-09; the old LINEAR 1→12 small px ≈ 48 full px was + far beyond what the established tools use: StreamFX defaults its Box blur Size to 5 and its author + stops downscale-based blurs around 8 px). The mask is computed once per frame (`TrySegment`) and + rendered per effect (`Render`), so several variants share one inference. No mask / segmenter + exception → passthrough + one log line, never a dropped layer. - **Config (slice 3):** per scene, on `WebcamSceneConfig` — `BackgroundEffect` + `BackgroundStrength` (0..1, default 0.5), persisted as two additive guarded columns on `WebcamSceneConfig` (`BackgroundEffect TEXT DEFAULT 'None'`, `BackgroundStrength REAL DEFAULT 0.5`, no version bump — @@ -668,7 +671,8 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro come from the established tools, not a guess: OBS's obs-backgroundremoval smoke-tests `temporal_smooth_factor` at its 0.85 default, Jitsi's webcam background filter (jitsi/jitsi-meet#17080) found 0.7/0.3 transformed best — a midpoint that still reacts to real movement; 0.5/0.5 leaves a - smearing edge ghost. Blur remains per-frame ("blur is heavy" = next work unit, on the radius curve). + smearing edge ghost. The blur "heavy" follow-up was fixed the same day: quadratic radius, 1→6 + small px (see the `Blur` bullet above). - **Perf (synthetic 1280×720, Debug build):** segment 1.6 ms, Remove 3.3 ms, Blur 17.5 ms per frame (Release is several × faster). Pixel loops are row-parallel, capped at min(4, cores/2); the bilinear upsampling is separable (horizontal rows precomputed per mask/small row, one vertical lerp @@ -689,6 +693,9 @@ which also `ScheduleSave`s). Per raw frame a background worker (latest-frame-dro — the ONE integration test for the jitter fix (a `SteppingMaskSegmenter` whose two consecutive masks disagree at an edge; asserts the second frame keeps a partial alpha where the raw mask would have cut to 0: x=6 → 58 upstairs of the raw 0, person core 255, background 0). + `Blur_default_strength_is_gentle_and_full_strength_is_strong` pins the quadratic radius mapping + (`BlurRadius(0.5)` = 2, `BlurRadius(1)` = 6) and the behaviour at a step edge: default smears + light ~9px into the dark half vs ~26px at full strength. ### Frame cadence — how a dropped frame became measurable (UNIT 0, 2026-10-05) diff --git a/ytLive.Tests/BackgroundEffectEngineTests.cs b/ytLive.Tests/BackgroundEffectEngineTests.cs index 1a09c38..79fc5a5 100644 --- a/ytLive.Tests/BackgroundEffectEngineTests.cs +++ b/ytLive.Tests/BackgroundEffectEngineTests.cs @@ -147,6 +147,67 @@ public class BackgroundEffectEngineTests } } + /// TASK 51 blur-heavy fix — the integration test: an all-background frame with one + /// bright pixel. The default 0.5 strength must keep the halo tight (quadratic curve: radius 2 + /// small px) while full strength spreads it far (radius 6) — the maker complained the default + /// was heavy under the old linear 1..12 range. + [Fact] + public void Blur_default_strength_is_gentle_and_full_strength_is_strong() + { + const int w = 128, h = 64; + var pixels = new byte[w * h * 4]; + for (var y = 0; y < h; y++) + for (var x = 0; x < w; x++) + { + var p = (y * w + x) * 4; + if (x < w / 2) pixels[p] = 200; + pixels[p + 1] = 180; + pixels[p + 2] = 160; + pixels[p + 3] = 255; + } + var frame = new VideoFrame(w, h, pixels) { IsOpaque = true }; + + // All background — the whole frame blurs, no person boundary to confuse the edge spread. + var mask = new SegmentationMask(4, 2, new float[8]); + var processor = new BackgroundEffectProcessor(new FakeSegmenter(mask)); + + var gentle = processor.Apply(frame, BackgroundEffect.Blur, BackgroundEffectProcessor.DefaultStrength); + var strong = processor.Apply(frame, BackgroundEffect.Blur, 1.0); + + // The radius mapping itself: quadratic, not the old linear 1..12. Default 0.5 = 2 small px + // (was 6), max = 6 small px (was 12) — the reason the default felt heavy before. + Assert.Equal(1, BackgroundEffectProcessor.BlurRadius(0.0)); + Assert.Equal(2, BackgroundEffectProcessor.BlurRadius(BackgroundEffectProcessor.DefaultStrength)); + Assert.Equal(BackgroundEffectProcessor.MinBlurRadius, BackgroundEffectProcessor.BlurRadius(0.0)); + Assert.Equal(BackgroundEffectProcessor.MaxBlurRadius, BackgroundEffectProcessor.BlurRadius(1.0)); + + // Behaviour at a step edge: the default smears light only ~9px into the dark half (measured), + // full strength smears ~26px — a gentle default with a real range above it. + var (reachGentle, sumGentle) = EdgeReach(gentle); + var (reachStrong, sumStrong) = EdgeReach(strong); + Assert.True(reachGentle <= 12, $"default blur reach {reachGentle}px (expected <= 12)"); + Assert.True(reachStrong >= 20, $"max blur reach {reachStrong}px (expected >= 20)"); + Assert.True(reachStrong > reachGentle); + Assert.True(sumStrong > sumGentle * 2, "max strength should smear visibly more light than default"); + } + + /// Largest x into the dark half (x = w/2) still carrying light, and the total light + /// in the dark half — the blur's smearing distance at a step edge. + private static (int Reach, double Sum) EdgeReach(VideoFrame f) + { + var edge = f.Width / 2; + var reach = 0; + double sum = 0; + for (var y = 0; y < f.Height; y++) + for (var x = edge; x < f.Width; x++) + { + var v = f.BgraPixels[(y * f.Width + x) * 4]; + if (v >= 32) reach = Math.Max(reach, x - edge); + sum += v; + } + return (reach, sum); + } + private static int Alpha(VideoFrame f, int x, int y) => f.BgraPixels[(y * f.Width + x) * 4 + 3]; private static string Fixture(string name) => Path.Combine(AppContext.BaseDirectory, "Fixtures", name);