diff --git a/apps/desktop/src/routes/editor/ConfigSidebar.tsx b/apps/desktop/src/routes/editor/ConfigSidebar.tsx
index d7bbba123c..2c209d620a 100644
--- a/apps/desktop/src/routes/editor/ConfigSidebar.tsx
+++ b/apps/desktop/src/routes/editor/ConfigSidebar.tsx
@@ -76,7 +76,6 @@ import {
import IconLucideBoxSelect from "~icons/lucide/box-select";
import IconLucideColumns2 from "~icons/lucide/columns-2";
import IconLucideEyeOff from "~icons/lucide/eye-off";
-import IconLucideGauge from "~icons/lucide/gauge";
import IconLucideGrid from "~icons/lucide/grid";
import IconLucideImageOff from "~icons/lucide/image-off";
import IconLucideKeyboard from "~icons/lucide/keyboard";
@@ -104,7 +103,14 @@ import { getColorPreviewBorderColor, hexToRgb, RgbInput } from "./color-utils";
import { type CornerRoundingType, useEditorContext } from "./context";
import { GradientEditor } from "./GradientEditor";
import { KeyboardTab } from "./KeyboardTab";
-import { evaluateMask, type MaskKind, type MaskSegment } from "./masks";
+import {
+ encodeMaskEffect,
+ getMaskEffect,
+ getMaskEffectAmount,
+ type MaskEffect,
+ type MaskKind,
+ type MaskSegment,
+} from "./masks";
import {
DEFAULT_BACKGROUND_PADDING,
DEFAULT_BACKGROUND_ROUNDING,
@@ -3780,7 +3786,7 @@ function MaskSegmentConfig(props: {
segmentIndex: number;
segment: MaskSegment;
}) {
- const { setProject, editorState } = useEditorContext();
+ const { setProject } = useEditorContext();
const updateSegment = (fn: (segment: MaskSegment) => void) => {
setProject(
@@ -3809,32 +3815,24 @@ function MaskSegmentConfig(props: {
});
});
- const currentAbsoluteTime = () =>
- editorState.previewTime ?? editorState.playbackTime ?? props.segment.start;
- const _maskState = () => evaluateMask(props.segment, currentAbsoluteTime());
-
- const clearKeyframes = (segment: MaskSegment) => {
- segment.keyframes.position = [];
- segment.keyframes.size = [];
- segment.keyframes.intensity = [];
- };
-
- const _setPosition = (value: { x: number; y: number }) =>
- updateSegment((segment) => {
- segment.center = value;
- clearKeyframes(segment);
- });
+ const maskEffect = () => getMaskEffect(props.segment);
+ const maskEffectAmount = () => getMaskEffectAmount(props.segment);
- const _setSize = (value: { x: number; y: number }) =>
+ const setMaskEffect = (effect: MaskEffect) =>
updateSegment((segment) => {
- segment.size = value;
- clearKeyframes(segment);
+ segment.pixelation = encodeMaskEffect(
+ effect,
+ getMaskEffectAmount(segment),
+ );
+ segment.opacity = 1;
+ segment.keyframes.intensity = [];
});
- const setIntensity = (value: number) =>
+ const setMaskEffectAmount = (amount: number) =>
updateSegment((segment) => {
- segment.opacity = value;
- clearKeyframes(segment);
+ segment.pixelation = encodeMaskEffect(getMaskEffect(segment), amount);
+ segment.opacity = 1;
+ segment.keyframes.intensity = [];
});
return (
@@ -3890,29 +3888,48 @@ function MaskSegmentConfig(props: {
- }>
- setIntensity(v)}
- minValue={0}
- maxValue={1}
- step={0.01}
- formatTooltip="%"
- />
+ }>
+ setMaskEffect(value as MaskEffect)}
+ >
+ {[
+ { value: "blur", label: "Blur" },
+ { value: "pixelate", label: "Pixelate" },
+ ].map((option) => (
+
+
+
+
+ {option.label}
+
+
+ ))}
+
- }>
+
+ ) : (
+
+ )
+ }
+ >
- updateSegment((segment) => {
- segment.pixelation = v;
- })
- }
- minValue={1}
+ value={[maskEffectAmount()]}
+ onChange={([v]) => setMaskEffectAmount(v)}
+ minValue={4}
maxValue={80}
step={1}
+ formatTooltip="px"
/>
diff --git a/apps/desktop/src/routes/editor/masks.test.ts b/apps/desktop/src/routes/editor/masks.test.ts
new file mode 100644
index 0000000000..ec3c0252ec
--- /dev/null
+++ b/apps/desktop/src/routes/editor/masks.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ defaultMaskSegment,
+ encodeMaskEffect,
+ getMaskEffect,
+ getMaskEffectAmount,
+} from "./masks";
+
+describe("mask effects", () => {
+ it("defaults new sensitive masks to a fully obscuring blur", () => {
+ const segment = defaultMaskSegment(0, 1);
+
+ expect(getMaskEffect(segment)).toBe("blur");
+ expect(getMaskEffectAmount(segment)).toBe(16);
+ expect(segment.opacity).toBe(1);
+ });
+
+ it("preserves legacy pixelation values", () => {
+ const segment = defaultMaskSegment(0, 1);
+ segment.pixelation = 18;
+
+ expect(getMaskEffect(segment)).toBe("pixelate");
+ expect(getMaskEffectAmount(segment)).toBe(18);
+ });
+
+ it("encodes blur as a privacy-safe legacy pixelation fallback", () => {
+ const segment = defaultMaskSegment(0, 1);
+ segment.pixelation = encodeMaskEffect("blur", 24);
+
+ expect(segment.pixelation).toBe(1024);
+ expect(getMaskEffect(segment)).toBe("blur");
+ expect(getMaskEffectAmount(segment)).toBe(24);
+ });
+
+ it("keeps effect amounts within the supported range", () => {
+ const segment = defaultMaskSegment(0, 1);
+ segment.pixelation = encodeMaskEffect("pixelate", Number.NaN);
+
+ expect(getMaskEffectAmount(segment)).toBe(16);
+ expect(encodeMaskEffect("pixelate", 1)).toBe(4);
+ expect(encodeMaskEffect("blur", 100)).toBe(1080);
+ });
+});
diff --git a/apps/desktop/src/routes/editor/masks.ts b/apps/desktop/src/routes/editor/masks.ts
index 1d4ace2883..b414d351ec 100644
--- a/apps/desktop/src/routes/editor/masks.ts
+++ b/apps/desktop/src/routes/editor/masks.ts
@@ -1,6 +1,16 @@
import type { XY } from "~/utils/tauri";
+import maskEffectContract from "../../../../../crates/project/mask-effects.json";
export type MaskKind = "sensitive" | "highlight";
+export type MaskEffect = "blur" | "pixelate";
+
+// Older versions interpret encoded blur as strong pixelation, keeping masked content private.
+const {
+ blurEncodingOffset: MASK_BLUR_ENCODING_OFFSET,
+ defaultAmount: DEFAULT_MASK_EFFECT_AMOUNT,
+ minAmount: MIN_MASK_EFFECT_AMOUNT,
+ maxAmount: MAX_MASK_EFFECT_AMOUNT,
+} = maskEffectContract;
export type MaskScalarKeyframe = {
time: number;
@@ -38,7 +48,37 @@ export type MaskSegment = {
export type MaskState = {
position: XY;
size: XY;
- intensity: number;
+};
+
+const normalizeMaskEffectAmount = (amount: number) => {
+ if (!Number.isFinite(amount) || amount <= 0) {
+ return DEFAULT_MASK_EFFECT_AMOUNT;
+ }
+ return Math.min(
+ Math.max(amount, MIN_MASK_EFFECT_AMOUNT),
+ MAX_MASK_EFFECT_AMOUNT,
+ );
+};
+
+export const encodeMaskEffect = (effect: MaskEffect, amount: number) => {
+ const normalizedAmount = normalizeMaskEffectAmount(amount);
+ return effect === "blur"
+ ? MASK_BLUR_ENCODING_OFFSET + normalizedAmount
+ : normalizedAmount;
+};
+
+export const getMaskEffect = (segment: MaskSegment): MaskEffect =>
+ segment.pixelation >= MASK_BLUR_ENCODING_OFFSET ? "blur" : "pixelate";
+
+export const getMaskEffectAmount = (segment: MaskSegment) => {
+ const storedAmount = Number.isFinite(segment.pixelation)
+ ? segment.pixelation
+ : DEFAULT_MASK_EFFECT_AMOUNT;
+ const decodedAmount =
+ getMaskEffect(segment) === "blur"
+ ? storedAmount - MASK_BLUR_ENCODING_OFFSET
+ : storedAmount;
+ return normalizeMaskEffectAmount(decodedAmount);
};
export const defaultMaskSegment = (
@@ -54,7 +94,7 @@ export const defaultMaskSegment = (
size: { x: 0.35, y: 0.35 },
feather: 0.1,
opacity: 1,
- pixelation: 18,
+ pixelation: encodeMaskEffect("blur", DEFAULT_MASK_EFFECT_AMOUNT),
darkness: 0.5,
fadeDuration: 0,
keyframes: { position: [], size: [], intensity: [] },
@@ -72,9 +112,7 @@ export const evaluateMask = (
x: Math.min(Math.max(segment.size.x, 0.01), 2),
y: Math.min(Math.max(segment.size.y, 0.01), 2),
};
- const intensity = Math.min(Math.max(segment.opacity, 0), 1);
-
- return { position, size, intensity };
+ return { position, size };
};
const sortByTime = (items: T[]) =>
diff --git a/crates/project/mask-effects.json b/crates/project/mask-effects.json
new file mode 100644
index 0000000000..d9c212dcc4
--- /dev/null
+++ b/crates/project/mask-effects.json
@@ -0,0 +1,6 @@
+{
+ "blurEncodingOffset": 1000,
+ "defaultAmount": 16,
+ "minAmount": 4,
+ "maxAmount": 80
+}
diff --git a/crates/project/src/configuration.rs b/crates/project/src/configuration.rs
index 7351cb83a5..59a3d159ba 100644
--- a/crates/project/src/configuration.rs
+++ b/crates/project/src/configuration.rs
@@ -2,6 +2,7 @@ use std::{
fmt,
ops::{Add, Div, Mul, Sub, SubAssign},
path::Path,
+ sync::LazyLock,
};
use serde::{Deserialize, Serialize};
@@ -758,6 +759,24 @@ pub enum MaskKind {
Highlight,
}
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MaskEffectContract {
+ pub blur_encoding_offset: f64,
+ pub default_amount: f64,
+ pub min_amount: f64,
+ pub max_amount: f64,
+}
+
+static MASK_EFFECT_CONTRACT: LazyLock = LazyLock::new(|| {
+ serde_json::from_str(include_str!("../mask-effects.json"))
+ .expect("embedded mask effect contract must be valid JSON")
+});
+
+pub fn mask_effect_contract() -> &'static MaskEffectContract {
+ &MASK_EFFECT_CONTRACT
+}
+
#[derive(Type, Serialize, Deserialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct MaskScalarKeyframe {
@@ -800,7 +819,7 @@ pub struct MaskSegment {
pub feather: f64,
#[serde(default = "MaskSegment::default_opacity")]
pub opacity: f64,
- #[serde(default)]
+ #[serde(default = "MaskSegment::default_pixelation")]
pub pixelation: f64,
#[serde(default)]
pub darkness: f64,
@@ -819,6 +838,10 @@ impl MaskSegment {
1.0
}
+ fn default_pixelation() -> f64 {
+ mask_effect_contract().default_amount
+ }
+
fn default_fade_duration() -> f64 {
0.15
}
@@ -1619,6 +1642,20 @@ mod tests {
assert_eq!(config.screen_motion_blur, 1.0);
}
+ #[test]
+ fn mask_without_pixelation_uses_a_visible_safe_default() {
+ let segment: MaskSegment = serde_json::from_value(serde_json::json!({
+ "start": 0.0,
+ "end": 1.0,
+ "maskType": "sensitive",
+ "center": { "x": 0.5, "y": 0.5 },
+ "size": { "x": 0.25, "y": 0.25 }
+ }))
+ .unwrap();
+
+ assert_eq!(segment.pixelation, 16.0);
+ }
+
#[test]
fn load_uses_cursor_motion_blur_when_screen_motion_blur_is_missing() {
let dir = tempfile::tempdir().unwrap();
diff --git a/crates/rendering/src/layers/mask.rs b/crates/rendering/src/layers/mask.rs
index 48b4935c72..2e36046ca4 100644
--- a/crates/rendering/src/layers/mask.rs
+++ b/crates/rendering/src/layers/mask.rs
@@ -1,13 +1,26 @@
use bytemuck::{Pod, Zeroable};
use wgpu::util::DeviceExt;
-use crate::{PreparedMask, RenderSession};
+use crate::{MaskRenderMode, PreparedMask, RenderSession};
+
+const PIXELATE_MODE: u32 = 0;
+const HIGHLIGHT_MODE: u32 = 1;
+const BLUR_HORIZONTAL_MODE: u32 = 2;
+const BLUR_VERTICAL_MODE: u32 = 3;
pub struct MaskLayer {
sampler: wgpu::Sampler,
pipeline: MaskPipeline,
}
+struct MaskPass<'a> {
+ source_texture_view: &'a wgpu::TextureView,
+ target_texture_view: &'a wgpu::TextureView,
+ render_pipeline: &'a wgpu::RenderPipeline,
+ load: wgpu::LoadOp,
+ uniforms: MaskUniforms,
+}
+
impl MaskLayer {
pub fn new(device: &wgpu::Device) -> Self {
Self {
@@ -15,8 +28,8 @@ impl MaskLayer {
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
address_mode_w: wgpu::AddressMode::ClampToEdge,
- mag_filter: wgpu::FilterMode::Nearest,
- min_filter: wgpu::FilterMode::Nearest,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::FilterMode::Nearest,
..Default::default()
}),
@@ -32,27 +45,88 @@ impl MaskLayer {
encoder: &mut wgpu::CommandEncoder,
mask: &PreparedMask,
) {
- let uniforms = MaskUniforms::from_mask(mask);
+ match mask.mode {
+ MaskRenderMode::Blur => {
+ self.render_pass(
+ device,
+ encoder,
+ MaskPass {
+ source_texture_view: session.current_texture_view(),
+ target_texture_view: session.other_texture_view(),
+ render_pipeline: &self.pipeline.render_pipeline,
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ uniforms: MaskUniforms::from_mask(mask, BLUR_HORIZONTAL_MODE),
+ },
+ );
+ self.render_pass(
+ device,
+ encoder,
+ MaskPass {
+ source_texture_view: session.other_texture_view(),
+ target_texture_view: session.current_texture_view(),
+ render_pipeline: &self.pipeline.blur_composite_pipeline,
+ load: wgpu::LoadOp::Load,
+ uniforms: MaskUniforms::from_mask(mask, BLUR_VERTICAL_MODE),
+ },
+ );
+ }
+ MaskRenderMode::Pixelate => {
+ self.render_single_pass(device, session, encoder, mask, PIXELATE_MODE);
+ }
+ MaskRenderMode::Highlight => {
+ self.render_single_pass(device, session, encoder, mask, HIGHLIGHT_MODE);
+ }
+ }
+ }
+
+ fn render_single_pass(
+ &self,
+ device: &wgpu::Device,
+ session: &mut RenderSession,
+ encoder: &mut wgpu::CommandEncoder,
+ mask: &PreparedMask,
+ mode: u32,
+ ) {
+ self.render_pass(
+ device,
+ encoder,
+ MaskPass {
+ source_texture_view: session.current_texture_view(),
+ target_texture_view: session.other_texture_view(),
+ render_pipeline: &self.pipeline.render_pipeline,
+ load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ uniforms: MaskUniforms::from_mask(mask, mode),
+ },
+ );
+ session.swap_textures();
+ }
+
+ fn render_pass(
+ &self,
+ device: &wgpu::Device,
+ encoder: &mut wgpu::CommandEncoder,
+ config: MaskPass<'_>,
+ ) {
let uniforms_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mask Uniform Buffer"),
- contents: bytemuck::cast_slice(&[uniforms]),
+ contents: bytemuck::cast_slice(&[config.uniforms]),
usage: wgpu::BufferUsages::UNIFORM,
});
let bind_group = self.pipeline.bind_group(
device,
&uniforms_buffer,
- session.current_texture_view(),
+ config.source_texture_view,
&self.sampler,
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Mask Pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
- view: session.other_texture_view(),
+ view: config.target_texture_view,
resolve_target: None,
ops: wgpu::Operations {
- load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
+ load: config.load,
store: wgpu::StoreOp::Store,
},
})],
@@ -61,12 +135,11 @@ impl MaskLayer {
occlusion_query_set: None,
});
- pass.set_pipeline(&self.pipeline.render_pipeline);
+ pass.set_pipeline(config.render_pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
drop(pass);
- session.swap_textures();
}
}
@@ -77,7 +150,7 @@ struct MaskUniforms {
rect_size: [f32; 2],
feather: f32,
opacity: f32,
- pixel_size: f32,
+ effect_size: f32,
darkness: f32,
mode: u32,
padding0: u32,
@@ -92,15 +165,15 @@ impl Default for MaskUniforms {
}
impl MaskUniforms {
- fn from_mask(mask: &PreparedMask) -> Self {
+ fn from_mask(mask: &PreparedMask, mode: u32) -> Self {
Self {
rect_center: [mask.center.x, mask.center.y],
rect_size: [mask.size.x, mask.size.y],
feather: mask.feather,
opacity: mask.opacity,
- pixel_size: mask.pixel_size,
+ effect_size: mask.effect_size,
darkness: mask.darkness,
- mode: mask.mode_value(),
+ mode,
padding0: 0,
output_size: [mask.output_size.x as f32, mask.output_size.y as f32],
padding1: [0.0; 2],
@@ -111,6 +184,7 @@ impl MaskUniforms {
pub struct MaskPipeline {
bind_group_layout: wgpu::BindGroupLayout,
render_pipeline: wgpu::RenderPipeline,
+ blur_composite_pipeline: wgpu::RenderPipeline,
}
impl MaskPipeline {
@@ -158,49 +232,68 @@ impl MaskPipeline {
push_constant_ranges: &[],
});
- let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
- label: Some("Mask Pipeline"),
- layout: Some(&pipeline_layout),
- vertex: wgpu::VertexState {
- module: &shader,
- entry_point: Some("vs_main"),
- buffers: &[],
- compilation_options: wgpu::PipelineCompilationOptions {
- constants: &[],
- zero_initialize_workgroup_memory: false,
+ let create_render_pipeline = |label, blend| {
+ device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some(label),
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ entry_point: Some("vs_main"),
+ buffers: &[],
+ compilation_options: wgpu::PipelineCompilationOptions {
+ constants: &[],
+ zero_initialize_workgroup_memory: false,
+ },
},
- },
- fragment: Some(wgpu::FragmentState {
- module: &shader,
- entry_point: Some("fs_main"),
- targets: &[Some(wgpu::ColorTargetState {
- format: wgpu::TextureFormat::Rgba8Unorm,
- blend: Some(wgpu::BlendState::REPLACE),
- write_mask: wgpu::ColorWrites::ALL,
- })],
- compilation_options: wgpu::PipelineCompilationOptions {
- constants: &[],
- zero_initialize_workgroup_memory: false,
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ entry_point: Some("fs_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ blend: Some(blend),
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ compilation_options: wgpu::PipelineCompilationOptions {
+ constants: &[],
+ zero_initialize_workgroup_memory: false,
+ },
+ }),
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ strip_index_format: None,
+ front_face: wgpu::FrontFace::Ccw,
+ cull_mode: Some(wgpu::Face::Back),
+ polygon_mode: wgpu::PolygonMode::Fill,
+ unclipped_depth: false,
+ conservative: false,
+ },
+ depth_stencil: None,
+ multisample: wgpu::MultisampleState::default(),
+ multiview: None,
+ cache: None,
+ })
+ };
+ let render_pipeline = create_render_pipeline("Mask Pipeline", wgpu::BlendState::REPLACE);
+ let blur_composite_pipeline = create_render_pipeline(
+ "Mask Blur Composite Pipeline",
+ wgpu::BlendState {
+ color: wgpu::BlendComponent {
+ src_factor: wgpu::BlendFactor::SrcAlpha,
+ dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
+ operation: wgpu::BlendOperation::Add,
+ },
+ alpha: wgpu::BlendComponent {
+ src_factor: wgpu::BlendFactor::Zero,
+ dst_factor: wgpu::BlendFactor::One,
+ operation: wgpu::BlendOperation::Add,
},
- }),
- primitive: wgpu::PrimitiveState {
- topology: wgpu::PrimitiveTopology::TriangleList,
- strip_index_format: None,
- front_face: wgpu::FrontFace::Ccw,
- cull_mode: Some(wgpu::Face::Back),
- polygon_mode: wgpu::PolygonMode::Fill,
- unclipped_depth: false,
- conservative: false,
},
- depth_stencil: None,
- multisample: wgpu::MultisampleState::default(),
- multiview: None,
- cache: None,
- });
+ );
Self {
bind_group_layout,
render_pipeline,
+ blur_composite_pipeline,
}
}
diff --git a/crates/rendering/src/lib.rs b/crates/rendering/src/lib.rs
index 33fed1e170..261a0aad99 100644
--- a/crates/rendering/src/lib.rs
+++ b/crates/rendering/src/lib.rs
@@ -1,7 +1,7 @@
use anyhow::Result;
use cap_project::{
AspectRatio, Camera, CameraShape, CameraXPosition, CameraYPosition, ClipOffsets, CornerStyle,
- Crop, CursorEvents, CursorType, FrameConfiguration, FrameStyle, MaskKind, ProjectConfiguration,
+ Crop, CursorEvents, CursorType, FrameConfiguration, FrameStyle, ProjectConfiguration,
RecordingMeta, SceneMode, StudioRecordingMeta, XY,
};
use composite_frame::CompositeVideoFrameUniforms;
@@ -216,19 +216,11 @@ pub struct RenderOptions {
pub preserve_screen_alpha: bool,
}
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MaskRenderMode {
- Sensitive,
+ Pixelate,
Highlight,
-}
-
-impl MaskRenderMode {
- fn from_kind(kind: MaskKind) -> Self {
- match kind {
- MaskKind::Sensitive => MaskRenderMode::Sensitive,
- MaskKind::Highlight => MaskRenderMode::Highlight,
- }
- }
+ Blur,
}
#[derive(Debug, Clone)]
@@ -237,21 +229,12 @@ pub struct PreparedMask {
pub size: XY,
pub feather: f32,
pub opacity: f32,
- pub pixel_size: f32,
+ pub effect_size: f32,
pub darkness: f32,
pub mode: MaskRenderMode,
pub output_size: XY,
}
-impl PreparedMask {
- fn mode_value(&self) -> u32 {
- match self.mode {
- MaskRenderMode::Sensitive => 0,
- MaskRenderMode::Highlight => 1,
- }
- }
-}
-
#[derive(Clone)]
pub struct RecordingSegmentDecoders {
screen: AsyncVideoDecoderHandle,
diff --git a/crates/rendering/src/mask.rs b/crates/rendering/src/mask.rs
index ff8c036e92..e61f121274 100644
--- a/crates/rendering/src/mask.rs
+++ b/crates/rendering/src/mask.rs
@@ -1,8 +1,10 @@
-use cap_project::{MaskKind, MaskScalarKeyframe, MaskSegment, MaskVectorKeyframe, XY};
+use cap_project::{
+ MaskKind, MaskScalarKeyframe, MaskSegment, MaskVectorKeyframe, XY, mask_effect_contract,
+};
use crate::{MaskRenderMode, PreparedMask};
-const MASK_PIXELATION_BASE_HEIGHT: f32 = 1080.0;
+const MASK_EFFECT_BASE_HEIGHT: f32 = 1080.0;
fn interpolate_vector(base: XY, keys: &[MaskVectorKeyframe], time: f64) -> XY {
if keys.is_empty() {
@@ -82,22 +84,28 @@ pub fn interpolate_masks(
let position =
interpolate_vector(segment.center, &segment.keyframes.position, relative_time);
let size = interpolate_vector(segment.size, &segment.keyframes.size, relative_time);
- let mut intensity =
- interpolate_scalar(segment.opacity, &segment.keyframes.intensity, relative_time);
-
- let fade_duration = match segment.mask_type {
- MaskKind::Sensitive => 0.0,
- MaskKind::Highlight => segment.fade_duration.max(0.0),
+ let (mode, opacity, effect_amount) = match segment.mask_type {
+ MaskKind::Sensitive => {
+ let (mode, effect_amount) = sensitive_effect(segment.pixelation);
+ (mode, 1.0, effect_amount)
+ }
+ MaskKind::Highlight => {
+ let mut intensity = interpolate_scalar(
+ segment.opacity,
+ &segment.keyframes.intensity,
+ relative_time,
+ );
+ let fade_duration = segment.fade_duration.max(0.0);
+ if fade_duration > 0.0 {
+ let time_since_start = (frame_time - segment.start).max(0.0);
+ let time_until_end = (segment.end - frame_time).max(0.0);
+ let fade_in = (time_since_start / fade_duration).min(1.0);
+ let fade_out = (time_until_end / fade_duration).min(1.0);
+ intensity *= fade_in * fade_out;
+ }
+ (MaskRenderMode::Highlight, intensity.clamp(0.0, 1.0), 0.0)
+ }
};
- if fade_duration > 0.0 {
- let time_since_start = (frame_time - segment.start).max(0.0);
- let time_until_end = (segment.end - frame_time).max(0.0);
-
- let fade_in = (time_since_start / fade_duration).min(1.0);
- let fade_out = (time_until_end / fade_duration).min(1.0);
-
- intensity *= fade_in * fade_out;
- }
let clamped_size = XY::new(size.x.clamp(0.01, 2.0), size.y.clamp(0.01, 2.0));
@@ -119,10 +127,10 @@ pub fn interpolate_masks(
clamped_size.y.clamp(0.0, 2.0) as f32,
),
feather,
- opacity: intensity.clamp(0.0, 1.0) as f32,
- pixel_size: scaled_pixel_size(output_size, segment.pixelation),
+ opacity: opacity as f32,
+ effect_size: scaled_effect_size(output_size, effect_amount),
darkness: segment.darkness.clamp(0.0, 1.0) as f32,
- mode: MaskRenderMode::from_kind(segment.mask_type),
+ mode,
output_size,
});
}
@@ -130,9 +138,38 @@ pub fn interpolate_masks(
prepared
}
-fn scaled_pixel_size(output_size: XY, pixelation: f64) -> f32 {
- let resolution_scale = output_size.y as f32 / MASK_PIXELATION_BASE_HEIGHT;
- (pixelation.max(1.0) as f32) * resolution_scale
+fn sensitive_effect(stored_effect: f64) -> (MaskRenderMode, f64) {
+ let contract = mask_effect_contract();
+ let stored_effect = if stored_effect.is_finite() {
+ stored_effect
+ } else {
+ contract.default_amount
+ };
+ if stored_effect >= contract.blur_encoding_offset {
+ (
+ MaskRenderMode::Blur,
+ normalize_effect_amount(stored_effect - contract.blur_encoding_offset),
+ )
+ } else {
+ (
+ MaskRenderMode::Pixelate,
+ normalize_effect_amount(stored_effect),
+ )
+ }
+}
+
+fn normalize_effect_amount(amount: f64) -> f64 {
+ let contract = mask_effect_contract();
+ if amount <= 0.0 {
+ contract.default_amount
+ } else {
+ amount.clamp(contract.min_amount, contract.max_amount)
+ }
+}
+
+fn scaled_effect_size(output_size: XY, amount: f64) -> f32 {
+ let resolution_scale = output_size.y as f32 / MASK_EFFECT_BASE_HEIGHT;
+ amount as f32 * resolution_scale
}
#[cfg(test)]
@@ -158,7 +195,7 @@ mod tests {
}
#[test]
- fn sensitive_mask_pixelation_scales_with_output_height() {
+ fn sensitive_mask_effect_scales_with_output_height() {
let segment = sample_segment();
let smaller = interpolate_masks(XY::new(872, 720), 1.0, std::slice::from_ref(&segment));
let low = interpolate_masks(XY::new(1308, 1080), 1.0, std::slice::from_ref(&segment));
@@ -167,8 +204,53 @@ mod tests {
assert_eq!(smaller.len(), 1);
assert_eq!(low.len(), 1);
assert_eq!(high.len(), 1);
- assert_eq!(smaller[0].pixel_size, 12.0);
- assert_eq!(low[0].pixel_size, 18.0);
- assert_eq!(high[0].pixel_size, 36.0);
+ assert_eq!(smaller[0].effect_size, 12.0);
+ assert_eq!(low[0].effect_size, 18.0);
+ assert_eq!(high[0].effect_size, 36.0);
+ }
+
+ #[test]
+ fn sensitive_mask_never_blends_with_source_content() {
+ let mut segment = sample_segment();
+ segment.opacity = 0.01;
+ segment.keyframes.intensity.push(MaskScalarKeyframe {
+ time: 1.0,
+ value: 0.01,
+ });
+
+ let masks = interpolate_masks(XY::new(1920, 1080), 1.0, &[segment]);
+
+ assert_eq!(masks[0].opacity, 1.0);
+ assert_eq!(masks[0].mode, MaskRenderMode::Pixelate);
+ }
+
+ #[test]
+ fn persisted_effect_encoding_matches_the_desktop_contract() {
+ for (stored_effect, expected_mode, expected_size) in [
+ (18.0, MaskRenderMode::Pixelate, 18.0),
+ (1001.0, MaskRenderMode::Blur, 4.0),
+ (1024.0, MaskRenderMode::Blur, 24.0),
+ (1080.0, MaskRenderMode::Blur, 80.0),
+ ] {
+ let mut segment = sample_segment();
+ segment.pixelation = stored_effect;
+
+ let masks = interpolate_masks(XY::new(1920, 1080), 1.0, &[segment]);
+
+ assert_eq!(masks[0].mode, expected_mode);
+ assert_eq!(masks[0].effect_size, expected_size);
+ assert_eq!(masks[0].opacity, 1.0);
+ }
+ }
+
+ #[test]
+ fn missing_legacy_pixelation_uses_a_visible_safe_default() {
+ let mut segment = sample_segment();
+ segment.pixelation = 0.0;
+
+ let masks = interpolate_masks(XY::new(1920, 1080), 1.0, &[segment]);
+
+ assert_eq!(masks[0].mode, MaskRenderMode::Pixelate);
+ assert_eq!(masks[0].effect_size, 16.0);
}
}
diff --git a/crates/rendering/src/shaders/mask.wgsl b/crates/rendering/src/shaders/mask.wgsl
index 5269801cc7..282a867ef9 100644
--- a/crates/rendering/src/shaders/mask.wgsl
+++ b/crates/rendering/src/shaders/mask.wgsl
@@ -3,7 +3,7 @@ struct Uniforms {
rect_size: vec2,
feather: f32,
opacity: f32,
- pixel_size: f32,
+ effect_size: f32,
darkness: f32,
mode: u32,
padding0: u32,
@@ -15,6 +15,11 @@ struct Uniforms {
@group(0) @binding(1) var source_texture: texture_2d;
@group(0) @binding(2) var source_sampler: sampler;
+const MODE_PIXELATE: u32 = 0u;
+const MODE_HIGHLIGHT: u32 = 1u;
+const MODE_BLUR_HORIZONTAL: u32 = 2u;
+const MODE_BLUR_VERTICAL: u32 = 3u;
+
struct VertexOutput {
@builtin(position) position: vec4,
@location(0) uv: vec2,
@@ -47,10 +52,45 @@ fn rect_mask(uv: vec2) -> f32 {
}
fn pixelate_sample(uv: vec2) -> vec4 {
- let px_size = max(uniforms.pixel_size, 1.0);
+ let px_size = max(uniforms.effect_size, 1.0);
let cell = px_size / uniforms.output_size;
let snapped = floor(uv / cell) * cell + cell * 0.5;
- return textureSample(source_texture, source_sampler, snapped);
+ let texture_size = textureDimensions(source_texture);
+ let max_coord = vec2(texture_size) - vec2(1);
+ let coord = clamp(
+ vec2(snapped * vec2(texture_size)),
+ vec2(0),
+ max_coord,
+ );
+ return textureLoad(source_texture, coord, 0);
+}
+
+fn blur_sample(uv: vec2, direction: vec2) -> vec4 {
+ let radius = max(uniforms.effect_size, 1.0);
+ let sample_step = direction * radius / (uniforms.output_size * 12.0);
+ var color = vec4(0.0);
+ var weight_sum = 0.0;
+
+ for (var index = -12; index <= 12; index++) {
+ let distance = f32(index) / 4.0;
+ let weight = exp(-0.5 * distance * distance);
+ color += textureSampleLevel(
+ source_texture,
+ source_sampler,
+ uv + f32(index) * sample_step,
+ 0.0,
+ ) * weight;
+ weight_sum += weight;
+ }
+
+ return color / weight_sum;
+}
+
+fn horizontal_blur_support(uv: vec2) -> bool {
+ let half_size = uniforms.rect_size * 0.5;
+ let delta = abs(uv - uniforms.rect_center);
+ let vertical_radius = uniforms.effect_size / uniforms.output_size.y;
+ return delta.x <= half_size.x && delta.y <= half_size.y + vertical_radius;
}
@fragment
@@ -58,14 +98,33 @@ fn fs_main(@location(0) uv: vec2) -> @location(0) vec4 {
let base = textureSample(source_texture, source_sampler, uv);
let mask = rect_mask(uv);
- if uniforms.mode == 0u {
+ if uniforms.mode == MODE_PIXELATE {
let pixelated = pixelate_sample(uv);
- let mix_amount = clamp(uniforms.opacity, 0.0, 1.0);
- let effect = mix(base, pixelated, mix_amount);
- return mix(base, effect, mask * mix_amount);
+ let effect = vec4(pixelated.rgb, base.a);
+ return mix(base, effect, mask);
+ }
+
+ if uniforms.mode == MODE_BLUR_HORIZONTAL {
+ if !horizontal_blur_support(uv) {
+ return base;
+ }
+ let blurred = blur_sample(uv, vec2(1.0, 0.0));
+ return vec4(blurred.rgb, base.a);
+ }
+
+ if uniforms.mode == MODE_BLUR_VERTICAL {
+ if mask <= 0.0 {
+ discard;
+ }
+ let blurred = blur_sample(uv, vec2(0.0, 1.0));
+ return vec4(blurred.rgb, mask);
+ }
+
+ if uniforms.mode == MODE_HIGHLIGHT {
+ let darkness = clamp(uniforms.darkness * uniforms.opacity, 0.0, 1.0);
+ let outside = vec4(base.rgb * (1.0 - darkness), base.a);
+ return mix(outside, base, mask);
}
- let darkness = clamp(uniforms.darkness * uniforms.opacity, 0.0, 1.0);
- let outside = vec4(base.rgb * (1.0 - darkness), base.a);
- return mix(outside, base, mask);
+ return base;
}