Skip to content

improve: add blur and pixelation mask effects#2012

Merged
richiemcilroy merged 6 commits into
mainfrom
improve/mask-effects
Jul 16, 2026
Merged

improve: add blur and pixelation mask effects#2012
richiemcilroy merged 6 commits into
mainfrom
improve/mask-effects

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Jul 16, 2026

Copy link
Copy Markdown
Member

Summary

  • replace opacity-based sensitive-mask intensity with blur and pixelation controls
  • keep sensitive masks fully opaque while preserving highlight intensity behavior
  • render smooth separable Gaussian blur and stable pixelation in previews and exports
  • preserve existing projects with privacy-safe effect defaults

Validation

  • pnpm exec biome check --write on the touched desktop files
  • pnpm --filter @cap/desktop exec vitest run src/routes/editor/masks.test.ts
  • pnpm --dir apps/desktop exec tsc --noEmit --pretty false -p tsconfig.json
  • cargo fmt --all -- --check
  • cargo test -p cap-project mask_without_pixelation_uses_a_visible_safe_default
  • cargo test -p cap-rendering mask::tests
  • cargo check -p cap-project -p cap-rendering
  • naga --validate 7 crates/rendering/src/shaders/mask.wgsl
  • native macOS blur and pixelation frame renders with mask opacity set to 0.01

Greptile Summary

This PR replaces the opacity-based sensitive mask intensity control with dedicated blur and pixelation effect modes. A shared mask-effects.json contract file is introduced as the single source of truth for encoding constants, consumed by both TypeScript (via import) and Rust (via include_str! + serde_json), directly addressing the constants-duplication concern raised in previous review threads.

  • Encoding scheme: blur is encoded as pixelation = 1000 + amount; legacy pixelation values stay as raw amounts. Both TS and Rust decode this identically from the JSON contract.
  • Two-pass Gaussian blur: horizontal pass writes to the intermediate buffer using REPLACE blend; vertical pass reads from that buffer and composites back with SrcAlpha/OneMinusSrcAlpha blend, correctly feathering the result over the original frame.
  • Sensitive mask opacity is hard-coded to 1.0 in interpolate_masks, so old projects with low-opacity sensitive masks are upgraded to fully opaque effects.

Confidence Score: 5/5

Safe to merge — the change is well-tested on both the TypeScript and Rust sides, and the JSON contract file cleanly eliminates the encoding drift risk that was the main concern in previous review rounds.

The encoding scheme is sound: the blur offset (1000) is far above any pixelation value the old UI could have produced (max 80), so legacy projects deserialize cleanly. The two-pass blur correctly avoids a texture swap by writing the composite result back to current_texture_view() in pass 2, while render_single_pass still swaps for pixelate/highlight. Switching pixelation to textureLoad sidesteps linear filtering artifacts. All behavior is backed by dedicated Rust and TypeScript tests.

No files require special attention.

Important Files Changed

Filename Overview
crates/project/mask-effects.json New contract file that acts as the single source of truth for blur encoding constants, read by both TypeScript and Rust to eliminate the previously flagged duplication.
apps/desktop/src/routes/editor/masks.ts Adds encoding/decoding helpers sourced from the JSON contract; normalizeMaskEffectAmount correctly handles NaN, infinity, and out-of-range inputs consistently with the Rust side.
apps/desktop/src/routes/editor/masks.test.ts New test file with good coverage of default values, legacy pixelation round-trip, blur encoding, and NaN/boundary clamping.
apps/desktop/src/routes/editor/ConfigSidebar.tsx Replaces opacity/intensity slider with a blur/pixelate radio group and a unified effect-amount slider; correctly forces segment.opacity to 1 and clears intensity keyframes when switching modes.
crates/project/src/configuration.rs Adds MaskEffectContract and a LazyLock for the embedded JSON; serde default for pixelation returns default_amount (16.0 = pixelate mode) for old projects missing the field.
crates/rendering/src/mask.rs sensitive_effect() correctly guards against non-finite pixelation before calling normalize_effect_amount; new tests cover encoding round-trip, opacity override, and zero-pixelation fallback.
crates/rendering/src/layers/mask.rs Two-pass blur architecture is correct: pass 1 clears other_texture and writes horizontal blur; pass 2 alpha-composites back to current_texture with LoadOp::Load, so no swap is needed.
crates/rendering/src/shaders/mask.wgsl blur_sample uses a 25-tap separable Gaussian with correct per-axis UV normalization. pixelate_sample switched to textureLoad for stable cell boundaries. horizontal_blur_support extends the horizontal pass vertically by one blur radius.
crates/rendering/src/lib.rs MaskRenderMode updated from Sensitive/Highlight to Pixelate/Blur/Highlight; PreparedMask.pixel_size renamed to effect_size; mode_value() helper removed.

Reviews (4): Last reviewed commit: "fix: make mask render dispatch explicit" | Re-trigger Greptile

@cursor

cursor Bot commented Jul 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Comment thread crates/rendering/src/shaders/mask.wgsl Outdated
Comment thread crates/rendering/src/mask.rs Outdated
Comment thread crates/rendering/src/layers/mask.rs Outdated
Comment thread crates/rendering/src/shaders/mask.wgsl Outdated
@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR


return color / weight_sum;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rect_mask() extends beyond rect_size by feather, so mode 3u can sample from the horizontal pass in that feather region too. Might be worth expanding the Y-support by uniforms.feather to avoid reintroducing partial-separable blur right at the feathered top/bottom edge.

Suggested change
return delta.x <= half_size.x && delta.y <= half_size.y + vertical_radius + uniforms.feather;

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

<RadioGroup
class="grid grid-cols-2 gap-2"
value={maskEffect()}
onChange={(value) => setMaskEffect(value as MaskEffect)}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor type-safety: it’d be nice to avoid the as MaskEffect cast here since RadioGroup can usually emit arbitrary strings.

Suggested change
onChange={(value) => setMaskEffect(value as MaskEffect)}
onChange={(value) => {
if (value === "blur" || value === "pixelate") {
setMaskEffect(value);
}
}}

@@ -1,6 +1,16 @@
import type { XY } from "~/utils/tauri";
import maskEffectContract from "../../../../../crates/project/mask-effects.json";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heads up: importing mask-effects.json from crates/ via a deep relative path can be a little fragile in Vite/Tauri setups (dev server fs allow / build sandboxing). If this ever becomes annoying, consider surfacing the contract via a small TS module/package (or generating it) so the desktop app doesn’t need to reach outside apps/desktop.


for (var index = -12; index <= 12; index++) {
let distance = f32(index) / 4.0;
let weight = exp(-0.5 * distance * distance);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perf nit: exp() inside the 25-tap blur loop is going to be pretty expensive per-fragment (and this runs twice for blur). If you see this show up in profiles, you can precompute the 25 Gaussian weights as const values (and a const weight sum) and just index into them in the loop.

@richiemcilroy richiemcilroy merged commit 976681e into main Jul 16, 2026
21 of 24 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant