Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion apps/desktop/src-tauri/src/recording.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use cap_media::{
use cap_project::{
CursorClickEvent, Platform, ProjectConfiguration, RecordingMeta, RecordingMetaInner,
SharingMeta, StudioRecordingMeta, TimelineConfiguration, TimelineSegment, ZoomMode,
ZoomSegment, cursor::CursorEvents,
BlurSegment,ZoomSegment, cursor::CursorEvents,
};
use cap_recording::{
CompletedStudioRecording, RecordingError, RecordingMode, StudioRecordingHandle,
Expand Down Expand Up @@ -985,6 +985,7 @@ fn project_config_from_recording(
} else {
Vec::new()
},
blur_segments:Some(Vec::new())
}),
..default_config.unwrap_or_default()
}
Expand Down
186 changes: 186 additions & 0 deletions apps/desktop/src/routes/editor/BlurOverlay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import { createElementBounds } from "@solid-primitives/bounds";
import { createEventListenerMap } from "@solid-primitives/event-listener";
import { createRoot, createSignal, For, Show } from "solid-js";
import { cx } from "cva";
import { useEditorContext } from "./context";


interface BlurRectangleProps {
rect: { x: number; y: number; width: number; height: number };
style: { left: string; top: string; width: string; height: string; filter?: string };
onUpdate: (rect: { x: number; y: number; width: number; height: number }) => void;
containerBounds: { width?: number | null; height?: number | null };
blurAmount: number;
isEditing: boolean;
}

export function BlurOverlay() {
const { project, setProject, editorState } = useEditorContext();

const [canvasContainerRef, setCanvasContainerRef] = createSignal<HTMLDivElement>();
const containerBounds = createElementBounds(canvasContainerRef);

const currentTime = () => editorState.previewTime ?? editorState.playbackTime ?? 0;


const activeBlurSegmentsWithIndex = () => {
return (project.timeline?.blurSegments || []).map((segment, index) => ({ segment, index })).filter(
({ segment }) => currentTime() >= segment.start && currentTime() <= segment.end
);
};

const updateBlurRect = (index: number, rect: { x: number; y: number; width: number; height: number }) => {
setProject("timeline", "blurSegments", index, "rect", rect);
};

const isSelected = (index: number) => {
const selection = editorState.timeline.selection;
return selection?.type === "blur" && selection.index === index;
};

return (
<div
ref={setCanvasContainerRef}
class="absolute inset-0 pointer-events-none"
>
<For each={activeBlurSegmentsWithIndex()}>
{({ segment, index }) => {
// Convert normalized coordinates to pixel coordinates
const rectStyle = () => {
const containerWidth = containerBounds.width ?? 1;
const containerHeight = containerBounds.height ?? 1;

return {
left: `${segment.rect.x * containerWidth}px`,
top: `${segment.rect.y * containerHeight}px`,
width: `${segment.rect.width * containerWidth}px`,
height: `${segment.rect.height * containerHeight}px`,
};
};

return (
<BlurRectangle
rect={segment.rect}
style={rectStyle()}
blurAmount={segment.blur_amount || 0}
onUpdate={(newRect) => updateBlurRect(index, newRect)}
containerBounds={containerBounds}
isEditing={isSelected(index)}
/>
);
}}
</For>
</div>
);
}



function BlurRectangle(props: BlurRectangleProps) {
const handleMouseDown = (e: MouseEvent, action: 'move' | 'resize', corner?: string) => {
e.preventDefault();
e.stopPropagation();

const containerWidth = props.containerBounds.width ?? 1;
const containerHeight = props.containerBounds.height ?? 1;

const startX = e.clientX;
const startY = e.clientY;
const startRect = { ...props.rect };

createRoot((dispose) => {
createEventListenerMap(window, {
mousemove: (moveEvent: MouseEvent) => {
const deltaX = (moveEvent.clientX - startX) / containerWidth;
const deltaY = (moveEvent.clientY - startY) / containerHeight;

let newRect = { ...startRect };

if (action === 'move') {
// Clamp the new position to stay within the 0.0 to 1.0 bounds
newRect.x = Math.max(0, Math.min(1 - newRect.width, startRect.x + deltaX));
newRect.y = Math.max(0, Math.min(1 - newRect.height, startRect.y + deltaY));
} else if (action === 'resize') {
// --- This resize logic needs the bounds check ---
let right = startRect.x + startRect.width;
let bottom = startRect.y + startRect.height;

if (corner?.includes('w')) { // West (left) handles
newRect.x = Math.max(0, startRect.x + deltaX);
newRect.width = right - newRect.x;
}
if (corner?.includes('n')) { // North (top) handles
newRect.y = Math.max(0, startRect.y + deltaY);
newRect.height = bottom - newRect.y;
}
if (corner?.includes('e')) { // East (right) handles
right = Math.min(1, right + deltaX);
newRect.width = right - newRect.x;
}
if (corner?.includes('s')) { // South (bottom) handles
bottom = Math.min(1, bottom + deltaY);
newRect.height = bottom - newRect.y;
}
}

// Ensure minimum size after any operation
if (newRect.width < 0.05) newRect.width = 0.05;
if (newRect.height < 0.05) newRect.height = 0.05;

props.onUpdate(newRect);
},
mouseup: () => {
dispose();
},
});
});
};
const scaledBlurAmount = () => (props.blurAmount ?? 0) * 20;
return (
<div
class={cx(
"absolute",
props.isEditing ? "pointer-events-auto border-2 border-blue-400 bg-blue-400/20" : "pointer-events-none border-none bg-transparent"
)}
style={{
...props.style,
"backdrop-filter": `blur(${scaledBlurAmount()}px)`,
"-webkit-backdrop-filter": `blur(${scaledBlurAmount()}px)`,
}}
>
<Show when={props.isEditing}>
{/* Main draggable area */}
<div
class="absolute inset-0 cursor-move"
onMouseDown={(e) => handleMouseDown(e, 'move')}
/>

{/* Resize handles */}
<div
class="absolute -top-1 -left-1 w-3 h-3 bg-blue-400 border border-white cursor-nw-resize rounded-full"
onMouseDown={(e) => handleMouseDown(e, 'resize', 'nw')}
/>
<div
class="absolute -top-1 -right-1 w-3 h-3 bg-blue-400 border border-white cursor-ne-resize rounded-full"
onMouseDown={(e) => handleMouseDown(e, 'resize', 'ne')}
/>
<div
class="absolute -bottom-1 -left-1 w-3 h-3 bg-blue-400 border border-white cursor-sw-resize rounded-full"
onMouseDown={(e) => handleMouseDown(e, 'resize', 'sw')}
/>
<div
class="absolute -bottom-1 -right-1 w-3 h-3 bg-blue-400 border border-white cursor-se-resize rounded-full"
onMouseDown={(e) => handleMouseDown(e, 'resize', 'se')}
/>

{/* Center label */}
{/* <div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="px-2 py-1 bg-blue-500 text-white text-xs rounded shadow-lg">
<IconCapBlur class="inline w-3 h-3 mr-1" />
Blur Area
</div>
</div> */}
</Show>
</div>
);
}
172 changes: 172 additions & 0 deletions apps/desktop/src/routes/editor/ConfigSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
type StereoMode,
type TimelineSegment,
type ZoomSegment,
type BlurSegment,
} from "~/utils/tauri";
import IconLucideSparkles from "~icons/lucide/sparkles";
import { CaptionsTab } from "./CaptionsTab";
Expand Down Expand Up @@ -585,6 +586,28 @@ export function ConfigSidebar() {
/>
)}
</Show>

<Show
when={(() => {
const blurSelection = selection();
if (blurSelection.type !== "blur") return;

const segment =
project.timeline?.blurSegments?.[blurSelection.index];
if (!segment) return;

return { selection: blurSelection, segment };
})()}
>
{(value) => (
<BlurSegmentConfig
segment={value().segment}
segmentIndex={value().selection.index}
/>
)}
</Show>


<Show
when={(() => {
const clipSegment = selection();
Expand Down Expand Up @@ -1975,6 +1998,155 @@ function ZoomSegmentConfig(props: {
);
}

function BlurSegmentConfig(props: {
segmentIndex: number;
segment: BlurSegment;
}) {
const {
project,
setProject,
editorInstance,
setEditorState,
projectHistory,
projectActions,
} = useEditorContext();

return (
<>
<div class="flex flex-row justify-between items-center">
<div class="flex gap-2 items-center">
<EditorButton
onClick={() => setEditorState("timeline", "selection", null)}
leftIcon={<IconLucideCheck />}
>
Done
</EditorButton>
</div>
<EditorButton
variant="danger"
onClick={() => {
projectActions.deleteBlurSegment(props.segmentIndex);
}}
leftIcon={<IconCapTrash />}
>
Delete
</EditorButton>
</div>

<Field name="Blur Intensity" icon={<IconCapBlur />}>
<Slider

value={[props.segment.blur_amount ?? 0]}
onChange={(v) =>
setProject(
"timeline",
"blurSegments",
props.segmentIndex,
"blur_amount",
v[0],
)
}

minValue={0}
maxValue={1}
step={0.01}
formatTooltip={(value) => `${Math.round(value * 100)}%`}
/>
</Field>

<Field name="Blur Area" icon={<IconCapBgBlur />}>
<div class="space-y-4">
<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-gray-11">X Position</label>
<Slider
value={[props.segment.rect.x * 100]}
onChange={(v) =>
setProject(
"timeline",
"blurSegments",
props.segmentIndex,
"rect",
"x",
v[0] / 100,
)
}
minValue={0}
maxValue={100}
step={0.1}
formatTooltip="%"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-11">Y Position</label>
<Slider
value={[props.segment.rect.y * 100]}
onChange={(v) =>
setProject(
"timeline",
"blurSegments",
props.segmentIndex,
"rect",
"y",
v[0] / 100,
)
}
minValue={0}
maxValue={100}
step={0.1}
formatTooltip="%"
/>
</div>
</div>

<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-gray-11">Width</label>
<Slider
value={[props.segment.rect.width * 100]}
onChange={(v) =>
setProject(
"timeline",
"blurSegments",
props.segmentIndex,
"rect",
"width",
v[0] / 100,
)
}
minValue={1}
maxValue={100}
step={0.1}
formatTooltip="%"
/>
</div>
<div class="flex-1">
<label class="text-xs text-gray-11">Height</label>
<Slider
value={[props.segment.rect.height * 100]}
onChange={(v) =>
setProject(
"timeline",
"blurSegments",
props.segmentIndex,
"rect",
"height",
v[0] / 100,
)
}
minValue={1}
maxValue={100}
step={0.1}
formatTooltip="%"
/>
</div>
</div>
</div>
</Field>
</>
);
}
Comment on lines +2001 to +2148
Copy link
Contributor

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

Unify blur_amount semantics across UI and renderer; verify icon import

  • The slider here treats blur_amount as a normalized 0–1 value (percent). That’s good and matches the renderer which scales by 8.0. However, BlurTrack initializes new segments with blur_amount: 8, which will be interpreted as 800% here and 64× in the shader after the 8.0 scale. This will cause extreme blur. Standardize blur_amount to 0–1 everywhere and fix the BlurTrack default.
  • Also verify that IconCapBlur is imported; it’s not visible in this file’s imports and will cause a compile error if missing.

Proposed changes outside this file:

  • In apps/desktop/src/routes/editor/Timeline/BlurTrack.tsx, change the default:
- blur_amount: 8,
+ blur_amount: 0.4,
  • In the same file, adjust the label from “x” to percent for consistency:
- return amount ? `${amount.toFixed(1)}x` : '...';
+ return amount != null ? `${Math.round(amount * 100)}%` : '...';

Within this file (optional UI consistency):

- <Field name="Blur Intensity" icon={<IconCapBlur />}>
+ <Field name="Blur Intensity" icon={<IconCapBlur />}>

If IconCapBlur isn’t imported, add it to the icon imports at the top of the file.


🏁 Script executed:

#!/bin/bash
set -e

# Locate BlurTrack.tsx
blur_file=$(fd BlurTrack.tsx -t f | head -n1)
echo "=== BlurTrack file: $blur_file ==="

# Show default blur_amount initialization
echo "=== Default blur_amount occurrences ==="
rg -n "blur_amount" -C3 "$blur_file"

# Show label formatting for blur amount in BlurTrack
echo "=== Label formatting for blur amount ==="
rg -n "toFixed" -C3 "$blur_file" || echo "No toFixed usage found"

# Locate ConfigSidebar.tsx
cs_file=$(fd ConfigSidebar.tsx -t f | head -n1)
echo "=== ConfigSidebar file: $cs_file ==="

# Check for IconCapBlur import
echo "=== IconCapBlur import lines ==="
rg -n "import.*IconCapBlur" -C2 "$cs_file" || echo "IconCapBlur not imported"

Length of output: 2275


Standardize blur_amount to 0–1 range and add missing IconCapBlur import

  • apps/desktop/src/routes/editor/Timeline/BlurTrack.tsx
    • At the segment initializer (line 114), change:

    -   blur_amount: 8,
    +   blur_amount: 0.4,

    • In the blurPercentage helper (around line 136), update the label from “x” to percent:

    - return amount ? `${amount.toFixed(1)}x` : '...';
    + return amount != null ? `${Math.round(amount * 100)}%` : '...';
  • apps/desktop/src/routes/editor/ConfigSidebar.tsx
    • Add the missing import for IconCapBlur alongside the other icons at the top of the file:

    import { IconLucideCheck, IconCapTrash, IconCapBlur, IconCapBgBlur } from '…';

These changes ensure that:

  • All blur_amount values are consistently treated as a 0–1 slider range (avoiding 800%/64× blur bugs).
  • The “Blur Intensity” field’s icon reference compiles without errors.
🤖 Prompt for AI Agents
In apps/desktop/src/routes/editor/ConfigSidebar.tsx around lines 2001 to 2148,
the Blur Intensity field uses IconCapBlur but that icon is not imported and the
component assumes blur_amount is a 0–1 value; add IconCapBlur to the icon import
list at the top of the file (alongside IconLucideCheck, IconCapTrash,
IconCapBgBlur) and ensure the Slider value and onChange continue to treat
props.segment.blur_amount as a 0–1 normalized value (no conversion to
percentages) so the component compiles and the blur_amount remains standardized
to the 0–1 range.


function ClipSegmentConfig(props: {
segmentIndex: number;
segment: TimelineSegment;
Expand Down
Loading