Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 58 additions & 41 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -3890,29 +3888,48 @@ function MaskSegmentConfig(props: {
</div>
</Field>
<Show when={props.segment.maskType === "sensitive"}>
<Field name="Intensity" icon={<IconLucideGauge class="size-4" />}>
<Slider
value={[props.segment.opacity]}
onChange={([v]) => setIntensity(v)}
minValue={0}
maxValue={1}
step={0.01}
formatTooltip="%"
/>
<Field name="Effect" icon={<IconLucideEyeOff class="size-4" />}>
<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);
}
}}

>
{[
{ value: "blur", label: "Blur" },
{ value: "pixelate", label: "Pixelate" },
].map((option) => (
<RadioGroup.Item
value={option.value}
class="rounded-lg border border-gray-3 transition-colors data-checked:border-blue-8 data-checked:bg-blue-3/40"
>
<RadioGroup.ItemInput class="sr-only" />
<RadioGroup.ItemLabel class="flex items-center gap-2 p-2 text-sm text-gray-12">
<RadioGroup.ItemControl class="size-4 rounded-full border border-gray-7 data-checked:border-blue-9 data-checked:bg-blue-9" />
{option.label}
</RadioGroup.ItemLabel>
</RadioGroup.Item>
))}
</RadioGroup>
</Field>
</Show>
<Show when={props.segment.maskType === "sensitive"}>
<Field name="Pixelation" icon={<IconLucideGrid class="size-4" />}>
<Field
name={maskEffect() === "blur" ? "Blur" : "Pixel Size"}
icon={
maskEffect() === "blur" ? (
<IconLucideWind class="size-4" />
) : (
<IconLucideGrid class="size-4" />
)
}
>
<Slider
value={[props.segment.pixelation]}
onChange={([v]) =>
updateSegment((segment) => {
segment.pixelation = v;
})
}
minValue={1}
value={[maskEffectAmount()]}
onChange={([v]) => setMaskEffectAmount(v)}
minValue={4}
maxValue={80}
step={1}
formatTooltip="px"
/>
</Field>
</Show>
Expand Down
44 changes: 44 additions & 0 deletions apps/desktop/src/routes/editor/masks.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
48 changes: 43 additions & 5 deletions apps/desktop/src/routes/editor/masks.ts
Original file line number Diff line number Diff line change
@@ -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.


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;
Expand Down Expand Up @@ -38,7 +48,37 @@ export type MaskSegment = {
export type MaskState = {
position: XY<number>;
size: XY<number>;
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 = (
Expand All @@ -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: [] },
Expand All @@ -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 = <T extends { time: number }>(items: T[]) =>
Expand Down
6 changes: 6 additions & 0 deletions crates/project/mask-effects.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"blurEncodingOffset": 1000,
"defaultAmount": 16,
"minAmount": 4,
"maxAmount": 80
}
39 changes: 38 additions & 1 deletion crates/project/src/configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::{
fmt,
ops::{Add, Div, Mul, Sub, SubAssign},
path::Path,
sync::LazyLock,
};

use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -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<MaskEffectContract> = 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 {
Expand Down Expand Up @@ -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,
Expand All @@ -819,6 +838,10 @@ impl MaskSegment {
1.0
}

fn default_pixelation() -> f64 {
mask_effect_contract().default_amount
}

fn default_fade_duration() -> f64 {
0.15
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading