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; }