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

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