Skip to content

Commit c4e38ae

Browse files
committed
feat: add cursor highlight overlay to video editor and export pipeline
Render cursor position overlay on recorded video using existing 10Hz cursor telemetry data. Four highlight styles: dot, circle, ring, and glow (soft radial gradient). Settings include color, size, opacity, and stroke width with live PixiJS preview and canvas-based export. Key implementation details: - Cursor telemetry remapped from display bounds to workArea coords using display metadata saved in cursor.json - Preview renders via PixiJS Graphics/Sprite in cameraContainer - Export renders via 2D canvas after annotations with rounded clip mask - Single "Cursor Highlight" settings panel with 4 styles - Full persistence, undo/redo, and dirty detection support
1 parent 4563641 commit c4e38ae

File tree

14 files changed

+1046
-2
lines changed

14 files changed

+1046
-2
lines changed

electron/electron-env.d.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,16 @@ interface Window {
6565
getCursorTelemetry: (videoPath?: string) => Promise<{
6666
success: boolean;
6767
samples: CursorTelemetryPoint[];
68+
display?: {
69+
boundsX: number;
70+
boundsY: number;
71+
boundsWidth: number;
72+
boundsHeight: number;
73+
workAreaX: number;
74+
workAreaY: number;
75+
workAreaWidth: number;
76+
workAreaHeight: number;
77+
};
6878
message?: string;
6979
error?: string;
7080
}>;

electron/ipc/handlers.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,11 +139,20 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) {
139139
if (pendingCursorSamples.length > 0) {
140140
await fs.writeFile(
141141
telemetryPath,
142-
JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2),
142+
JSON.stringify(
143+
{
144+
version: CURSOR_TELEMETRY_VERSION,
145+
samples: pendingCursorSamples,
146+
display: captureDisplayInfo ?? undefined,
147+
},
148+
null,
149+
2,
150+
),
143151
"utf-8",
144152
);
145153
}
146154
pendingCursorSamples = [];
155+
captureDisplayInfo = null;
147156

148157
const sessionManifestPath = path.join(
149158
RECORDINGS_DIR,
@@ -185,6 +194,19 @@ function stopCursorCapture() {
185194
}
186195
}
187196

197+
// Store the capture display info so we can save it to cursor.json for correct mapping
198+
let captureDisplayInfo: {
199+
boundsX: number;
200+
boundsY: number;
201+
boundsWidth: number;
202+
boundsHeight: number;
203+
workAreaX: number;
204+
workAreaY: number;
205+
workAreaWidth: number;
206+
workAreaHeight: number;
207+
scaleFactor: number;
208+
} | null = null;
209+
188210
function sampleCursorPoint() {
189211
const cursor = screen.getCursorScreenPoint();
190212
const sourceDisplayId = Number(selectedSource?.display_id);
@@ -196,6 +218,21 @@ function sampleCursorPoint() {
196218
const width = Math.max(1, bounds.width);
197219
const height = Math.max(1, bounds.height);
198220

221+
// Save display info on first sample
222+
if (!captureDisplayInfo) {
223+
captureDisplayInfo = {
224+
boundsX: bounds.x,
225+
boundsY: bounds.y,
226+
boundsWidth: bounds.width,
227+
boundsHeight: bounds.height,
228+
workAreaX: display.workArea.x,
229+
workAreaY: display.workArea.y,
230+
workAreaWidth: display.workArea.width,
231+
workAreaHeight: display.workArea.height,
232+
scaleFactor: display.scaleFactor,
233+
};
234+
}
235+
199236
const cx = clamp((cursor.x - bounds.x) / width, 0, 1);
200237
const cy = clamp((cursor.y - bounds.y) / height, 0, 1);
201238

@@ -369,6 +406,7 @@ export function registerIpcHandlers(
369406

370407
ipcMain.handle("set-recording-state", (_, recording: boolean) => {
371408
if (recording) {
409+
captureDisplayInfo = null;
372410
stopCursorCapture();
373411
activeCursorSamples = [];
374412
pendingCursorSamples = [];
@@ -426,7 +464,8 @@ export function registerIpcHandlers(
426464
})
427465
.sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs);
428466

429-
return { success: true, samples };
467+
const display = parsed?.display ?? undefined;
468+
return { success: true, samples, display };
430469
} catch (error) {
431470
const nodeError = error as NodeJS.ErrnoException;
432471
if (nodeError.code === "ENOENT") {

src/components/video-editor/SettingsPanel.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import Block from "@uiw/react-color-block";
22
import {
33
Bug,
4+
Circle,
45
Crop,
6+
Crosshair,
57
Download,
68
Film,
79
FolderOpen,
810
Image,
911
Lock,
12+
Mouse,
1013
Palette,
1114
Save,
1215
Sparkles,
1316
Star,
17+
Target,
1418
Trash2,
1519
Unlock,
1620
Upload,
@@ -41,6 +45,7 @@ import type {
4145
AnnotationRegion,
4246
AnnotationType,
4347
CropRegion,
48+
CursorStyle,
4449
FigureData,
4550
PlaybackSpeed,
4651
ZoomDepth,
@@ -132,6 +137,23 @@ interface SettingsPanelProps {
132137
selectedSpeedValue?: PlaybackSpeed | null;
133138
onSpeedChange?: (speed: PlaybackSpeed) => void;
134139
onSpeedDelete?: (id: string) => void;
140+
// Cursor settings
141+
hasCursorTelemetry?: boolean;
142+
showCursorHighlight?: boolean;
143+
onShowCursorHighlightChange?: (show: boolean) => void;
144+
cursorStyle?: CursorStyle;
145+
onCursorStyleChange?: (style: CursorStyle) => void;
146+
cursorColor?: string;
147+
onCursorColorChange?: (color: string) => void;
148+
cursorSize?: number;
149+
onCursorSizeChange?: (size: number) => void;
150+
onCursorSizeCommit?: () => void;
151+
cursorOpacity?: number;
152+
onCursorOpacityChange?: (opacity: number) => void;
153+
onCursorOpacityCommit?: () => void;
154+
cursorStrokeWidth?: number;
155+
onCursorStrokeWidthChange?: (width: number) => void;
156+
onCursorStrokeWidthCommit?: () => void;
135157
}
136158

137159
export default SettingsPanel;
@@ -197,6 +219,23 @@ export function SettingsPanel({
197219
selectedSpeedValue,
198220
onSpeedChange,
199221
onSpeedDelete,
222+
// Cursor settings
223+
hasCursorTelemetry = false,
224+
showCursorHighlight = false,
225+
onShowCursorHighlightChange,
226+
cursorStyle = "dot",
227+
onCursorStyleChange,
228+
cursorColor = "#ffcc00",
229+
onCursorColorChange,
230+
cursorSize = 32,
231+
onCursorSizeChange,
232+
onCursorSizeCommit,
233+
cursorOpacity = 0.6,
234+
onCursorOpacityChange,
235+
onCursorOpacityCommit,
236+
cursorStrokeWidth = 2,
237+
onCursorStrokeWidthChange,
238+
onCursorStrokeWidthCommit,
200239
}: SettingsPanelProps) {
201240
const [wallpaperPaths, setWallpaperPaths] = useState<string[]>([]);
202241
const [customImages, setCustomImages] = useState<string[]>([]);
@@ -665,6 +704,135 @@ export function SettingsPanel({
665704
</AccordionContent>
666705
</AccordionItem>
667706

707+
<AccordionItem value="cursor" className="border-white/5 rounded-xl bg-white/[0.02] px-3">
708+
<AccordionTrigger className="py-2.5 hover:no-underline">
709+
<div className="flex items-center gap-2">
710+
<Mouse className="w-4 h-4 text-[#34B27B]" />
711+
<span className="text-xs font-medium">Cursor Highlight</span>
712+
</div>
713+
</AccordionTrigger>
714+
<AccordionContent className="pb-3">
715+
{!hasCursorTelemetry && (
716+
<div className="text-[10px] text-slate-500 mb-2 p-2 rounded-lg bg-white/5 border border-white/5">
717+
No cursor data — re-record to enable cursor effects.
718+
</div>
719+
)}
720+
721+
<div className={cn(!hasCursorTelemetry && "opacity-40 pointer-events-none")}>
722+
<div className="flex items-center justify-between p-2 rounded-lg bg-white/5 border border-white/5 mb-2">
723+
<div className="text-[10px] font-medium text-slate-300">
724+
Show Cursor Highlight
725+
</div>
726+
<Switch
727+
checked={showCursorHighlight}
728+
onCheckedChange={onShowCursorHighlightChange}
729+
className="data-[state=checked]:bg-[#34B27B] scale-90"
730+
/>
731+
</div>
732+
733+
<div className={cn(!showCursorHighlight && "opacity-40 pointer-events-none")}>
734+
<div className="text-[10px] font-medium text-slate-400 mb-1.5 px-0.5">Style</div>
735+
<div className="grid grid-cols-4 gap-1 mb-2">
736+
{(["dot", "circle", "ring", "glow"] as const).map((style) => (
737+
<button
738+
key={style}
739+
type="button"
740+
onClick={() => onCursorStyleChange?.(style)}
741+
className={cn(
742+
"flex items-center justify-center gap-1 p-1.5 rounded-md text-[10px] font-medium border transition-all",
743+
cursorStyle === style
744+
? "bg-[#34B27B]/20 border-[#34B27B] text-[#34B27B]"
745+
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10",
746+
)}
747+
>
748+
{style === "dot" && <Circle className="w-3 h-3" />}
749+
{style === "circle" && <Crosshair className="w-3 h-3" />}
750+
{style === "ring" && <Target className="w-3 h-3" />}
751+
{style === "glow" && <Sparkles className="w-3 h-3" />}
752+
<span className="capitalize">{style}</span>
753+
</button>
754+
))}
755+
</div>
756+
757+
<div className="text-[10px] font-medium text-slate-400 mb-1.5 px-0.5">Color</div>
758+
<div className="mb-2">
759+
<Block
760+
color={cursorColor}
761+
colors={[
762+
"#ffcc00",
763+
"#ffffff",
764+
"#000000",
765+
"#ff0000",
766+
"#ff6600",
767+
"#00ff00",
768+
"#0088ff",
769+
"#ff66cc",
770+
"#34B27B",
771+
"#8b5cf6",
772+
]}
773+
onChange={(color) => onCursorColorChange?.(color.hex)}
774+
className="!bg-transparent !shadow-none !border-0 !p-0"
775+
/>
776+
</div>
777+
778+
<div className="grid grid-cols-2 gap-2">
779+
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
780+
<div className="flex items-center justify-between mb-1">
781+
<div className="text-[10px] font-medium text-slate-300">Size</div>
782+
<span className="text-[10px] text-slate-500 font-mono">{cursorSize}px</span>
783+
</div>
784+
<Slider
785+
value={[cursorSize]}
786+
onValueChange={(values) => onCursorSizeChange?.(values[0])}
787+
onValueCommit={() => onCursorSizeCommit?.()}
788+
min={16}
789+
max={64}
790+
step={1}
791+
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
792+
/>
793+
</div>
794+
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
795+
<div className="flex items-center justify-between mb-1">
796+
<div className="text-[10px] font-medium text-slate-300">Opacity</div>
797+
<span className="text-[10px] text-slate-500 font-mono">
798+
{Math.round(cursorOpacity * 100)}%
799+
</span>
800+
</div>
801+
<Slider
802+
value={[cursorOpacity]}
803+
onValueChange={(values) => onCursorOpacityChange?.(values[0])}
804+
onValueCommit={() => onCursorOpacityCommit?.()}
805+
min={0}
806+
max={1}
807+
step={0.01}
808+
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
809+
/>
810+
</div>
811+
</div>
812+
{(cursorStyle === "circle" || cursorStyle === "ring") && (
813+
<div className="p-2 rounded-lg bg-white/5 border border-white/5 mt-2">
814+
<div className="flex items-center justify-between mb-1">
815+
<div className="text-[10px] font-medium text-slate-300">Stroke</div>
816+
<span className="text-[10px] text-slate-500 font-mono">
817+
{cursorStrokeWidth}px
818+
</span>
819+
</div>
820+
<Slider
821+
value={[cursorStrokeWidth]}
822+
onValueChange={(values) => onCursorStrokeWidthChange?.(values[0])}
823+
onValueCommit={() => onCursorStrokeWidthCommit?.()}
824+
min={1}
825+
max={6}
826+
step={0.5}
827+
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
828+
/>
829+
</div>
830+
)}
831+
</div>
832+
</div>
833+
</AccordionContent>
834+
</AccordionItem>
835+
668836
<AccordionItem
669837
value="background"
670838
className="border-white/5 rounded-xl bg-white/[0.02] px-3"

0 commit comments

Comments
 (0)