Skip to content
Open
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
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