diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index 07253480..9681c6ca 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -65,6 +65,16 @@ interface Window { getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; + display?: { + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; + workAreaX: number; + workAreaY: number; + workAreaWidth: number; + workAreaHeight: number; + }; message?: string; error?: string; }>; diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 7738c48f..737e3ebc 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -139,11 +139,20 @@ async function storeRecordedSessionFiles(payload: StoreRecordedSessionInput) { if (pendingCursorSamples.length > 0) { await fs.writeFile( telemetryPath, - JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, null, 2), + JSON.stringify( + { + version: CURSOR_TELEMETRY_VERSION, + samples: pendingCursorSamples, + display: captureDisplayInfo ?? undefined, + }, + null, + 2, + ), "utf-8", ); } pendingCursorSamples = []; + captureDisplayInfo = null; const sessionManifestPath = path.join( RECORDINGS_DIR, @@ -185,6 +194,19 @@ function stopCursorCapture() { } } +// Store the capture display info so we can save it to cursor.json for correct mapping +let captureDisplayInfo: { + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; + workAreaX: number; + workAreaY: number; + workAreaWidth: number; + workAreaHeight: number; + scaleFactor: number; +} | null = null; + function sampleCursorPoint() { const cursor = screen.getCursorScreenPoint(); const sourceDisplayId = Number(selectedSource?.display_id); @@ -196,6 +218,21 @@ function sampleCursorPoint() { const width = Math.max(1, bounds.width); const height = Math.max(1, bounds.height); + // Save display info on first sample + if (!captureDisplayInfo) { + captureDisplayInfo = { + boundsX: bounds.x, + boundsY: bounds.y, + boundsWidth: bounds.width, + boundsHeight: bounds.height, + workAreaX: display.workArea.x, + workAreaY: display.workArea.y, + workAreaWidth: display.workArea.width, + workAreaHeight: display.workArea.height, + scaleFactor: display.scaleFactor, + }; + } + const cx = clamp((cursor.x - bounds.x) / width, 0, 1); const cy = clamp((cursor.y - bounds.y) / height, 0, 1); @@ -369,6 +406,7 @@ export function registerIpcHandlers( ipcMain.handle("set-recording-state", (_, recording: boolean) => { if (recording) { + captureDisplayInfo = null; stopCursorCapture(); activeCursorSamples = []; pendingCursorSamples = []; @@ -426,7 +464,8 @@ export function registerIpcHandlers( }) .sort((a: CursorTelemetryPoint, b: CursorTelemetryPoint) => a.timeMs - b.timeMs); - return { success: true, samples }; + const display = parsed?.display ?? undefined; + return { success: true, samples, display }; } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === "ENOENT") { diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4e0c0676..5c894b12 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -1,16 +1,20 @@ import Block from "@uiw/react-color-block"; import { Bug, + Circle, Crop, + Crosshair, Download, Film, FolderOpen, Image, Lock, + Mouse, Palette, Save, Sparkles, Star, + Target, Trash2, Unlock, Upload, @@ -41,6 +45,7 @@ import type { AnnotationRegion, AnnotationType, CropRegion, + CursorStyle, FigureData, PlaybackSpeed, ZoomDepth, @@ -132,6 +137,23 @@ interface SettingsPanelProps { selectedSpeedValue?: PlaybackSpeed | null; onSpeedChange?: (speed: PlaybackSpeed) => void; onSpeedDelete?: (id: string) => void; + // Cursor settings + hasCursorTelemetry?: boolean; + showCursorHighlight?: boolean; + onShowCursorHighlightChange?: (show: boolean) => void; + cursorStyle?: CursorStyle; + onCursorStyleChange?: (style: CursorStyle) => void; + cursorColor?: string; + onCursorColorChange?: (color: string) => void; + cursorSize?: number; + onCursorSizeChange?: (size: number) => void; + onCursorSizeCommit?: () => void; + cursorOpacity?: number; + onCursorOpacityChange?: (opacity: number) => void; + onCursorOpacityCommit?: () => void; + cursorStrokeWidth?: number; + onCursorStrokeWidthChange?: (width: number) => void; + onCursorStrokeWidthCommit?: () => void; } export default SettingsPanel; @@ -197,6 +219,23 @@ export function SettingsPanel({ selectedSpeedValue, onSpeedChange, onSpeedDelete, + // Cursor settings + hasCursorTelemetry = false, + showCursorHighlight = false, + onShowCursorHighlightChange, + cursorStyle = "dot", + onCursorStyleChange, + cursorColor = "#ffcc00", + onCursorColorChange, + cursorSize = 32, + onCursorSizeChange, + onCursorSizeCommit, + cursorOpacity = 0.6, + onCursorOpacityChange, + onCursorOpacityCommit, + cursorStrokeWidth = 2, + onCursorStrokeWidthChange, + onCursorStrokeWidthCommit, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); @@ -665,6 +704,135 @@ export function SettingsPanel({ + + +
+ + Cursor Highlight +
+
+ + {!hasCursorTelemetry && ( +
+ No cursor data — re-record to enable cursor effects. +
+ )} + +
+
+
+ Show Cursor Highlight +
+ +
+ +
+
Style
+
+ {(["dot", "circle", "ring", "glow"] as const).map((style) => ( + + ))} +
+ +
Color
+
+ onCursorColorChange?.(color.hex)} + className="!bg-transparent !shadow-none !border-0 !p-0" + /> +
+ +
+
+
+
Size
+ {cursorSize}px +
+ onCursorSizeChange?.(values[0])} + onValueCommit={() => onCursorSizeCommit?.()} + min={16} + max={64} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+
+
Opacity
+ + {Math.round(cursorOpacity * 100)}% + +
+ onCursorOpacityChange?.(values[0])} + onValueCommit={() => onCursorOpacityCommit?.()} + min={0} + max={1} + step={0.01} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+
+ {(cursorStyle === "circle" || cursorStyle === "ring") && ( +
+
+
Stroke
+ + {cursorStrokeWidth}px + +
+ onCursorStrokeWidthChange?.(values[0])} + onValueCommit={() => onCursorStrokeWidthCommit?.()} + min={1} + max={6} + step={0.5} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3" + /> +
+ )} +
+
+
+
+ ([]); + const [cursorDisplayInfo, setCursorDisplayInfo] = useState<{ + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; + workAreaX: number; + workAreaY: number; + workAreaWidth: number; + workAreaHeight: number; + } | null>(null); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); const [selectedSpeedId, setSelectedSpeedId] = useState(null); @@ -173,6 +189,12 @@ export default function VideoEditor() { speedRegions: normalizedEditor.speedRegions, annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, + showCursorHighlight: normalizedEditor.showCursorHighlight, + cursorStyle: normalizedEditor.cursorStyle, + cursorColor: normalizedEditor.cursorColor, + cursorSize: normalizedEditor.cursorSize, + cursorOpacity: normalizedEditor.cursorOpacity, + cursorStrokeWidth: normalizedEditor.cursorStrokeWidth, }); setExportQuality(normalizedEditor.exportQuality); setExportFormat(normalizedEditor.exportFormat); @@ -240,6 +262,12 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, exportQuality, exportFormat, gifFrameRate, @@ -261,6 +289,12 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, exportQuality, exportFormat, gifFrameRate, @@ -352,6 +386,12 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, exportQuality, exportFormat, gifFrameRate, @@ -404,6 +444,12 @@ export default function VideoEditor() { speedRegions, annotationRegions, aspectRatio, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, exportQuality, exportFormat, gifFrameRate, @@ -482,6 +528,7 @@ export default function VideoEditor() { const result = await window.electronAPI.getCursorTelemetry(sourcePath); if (mounted) { setCursorTelemetry(result.success ? result.samples : []); + setCursorDisplayInfo(result.display ?? null); } } catch (telemetryError) { console.warn("Unable to load cursor telemetry:", telemetryError); @@ -1023,6 +1070,14 @@ export default function VideoEditor() { annotationRegions, previewWidth, previewHeight, + cursorTelemetry, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, + cursorDisplayInfo, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1150,6 +1205,14 @@ export default function VideoEditor() { annotationRegions, previewWidth, previewHeight, + cursorTelemetry, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, + cursorDisplayInfo, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1212,6 +1275,14 @@ export default function VideoEditor() { annotationRegions, isPlaying, aspectRatio, + cursorTelemetry, + showCursorHighlight, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, + cursorDisplayInfo, exportQuality, handleExportSaved, ], @@ -1377,6 +1448,14 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTelemetry={cursorTelemetry} + showCursorHighlight={showCursorHighlight} + cursorStyle={cursorStyle} + cursorColor={cursorColor} + cursorSize={cursorSize} + cursorOpacity={cursorOpacity} + cursorStrokeWidth={cursorStrokeWidth} + cursorDisplayInfo={cursorDisplayInfo} /> @@ -1516,6 +1595,22 @@ export default function VideoEditor() { } onSpeedChange={handleSpeedChange} onSpeedDelete={handleSpeedDelete} + hasCursorTelemetry={cursorTelemetry.length > 0} + showCursorHighlight={showCursorHighlight} + onShowCursorHighlightChange={(v) => pushState({ showCursorHighlight: v })} + cursorStyle={cursorStyle} + onCursorStyleChange={(v) => pushState({ cursorStyle: v })} + cursorColor={cursorColor} + onCursorColorChange={(v) => pushState({ cursorColor: v })} + cursorSize={cursorSize} + onCursorSizeChange={(v) => updateState({ cursorSize: v })} + onCursorSizeCommit={commitState} + cursorOpacity={cursorOpacity} + onCursorOpacityChange={(v) => updateState({ cursorOpacity: v })} + onCursorOpacityCommit={commitState} + cursorStrokeWidth={cursorStrokeWidth} + onCursorStrokeWidthChange={(v) => updateState({ cursorStrokeWidth: v })} + onCursorStrokeWidthCommit={commitState} /> diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 85029fcc..81cd62b1 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -28,6 +28,8 @@ import { import { AnnotationOverlay } from "./AnnotationOverlay"; import { type AnnotationRegion, + type CursorStyle, + type CursorTelemetryPoint, type SpeedRegion, type TrimRegion, ZOOM_DEPTH_SCALES, @@ -84,6 +86,24 @@ interface VideoPlaybackProps { onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + // Cursor overlay + cursorTelemetry?: CursorTelemetryPoint[]; + showCursorHighlight?: boolean; + cursorStyle?: CursorStyle; + cursorColor?: string; + cursorSize?: number; + cursorOpacity?: number; + cursorStrokeWidth?: number; + cursorDisplayInfo?: { + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; + workAreaX: number; + workAreaY: number; + workAreaWidth: number; + workAreaHeight: number; + } | null; } export interface VideoPlaybackRef { @@ -128,6 +148,15 @@ const VideoPlayback = forwardRef( onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + // Cursor overlay + cursorTelemetry = [], + showCursorHighlight = false, + cursorStyle = "dot", + cursorColor = "#ffcc00", + cursorSize = 32, + cursorOpacity = 0.6, + cursorStrokeWidth = 2, + cursorDisplayInfo = null, }, ref, ) => { @@ -171,6 +200,11 @@ const VideoPlayback = forwardRef( const baseMaskRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); const cropBoundsRef = useRef({ startX: 0, endX: 0, startY: 0, endY: 0 }); const maskGraphicsRef = useRef(null); + const cursorGraphicsRef = useRef(null); + const cursorGlowSpriteRef = useRef(null); + const cursorGlowCacheRef = useRef<{ color: string; size: number; opacity: number } | null>( + null, + ); const isPlayingRef = useRef(isPlaying); const isSeekingRef = useRef(false); const allowPlaybackRef = useRef(false); @@ -576,6 +610,18 @@ const VideoPlayback = forwardRef( videoContainerRef.current = videoContainer; cameraContainer.addChild(videoContainer); + // Cursor graphics - rendered on top of video inside cameraContainer + const cursorGfx = new Graphics(); + cursorGraphicsRef.current = cursorGfx; + cameraContainer.addChild(cursorGfx); + + // Glow sprite - uses offscreen canvas for smooth radial gradient + const glowSprite = new Sprite(); + glowSprite.anchor.set(0.5, 0.5); + glowSprite.visible = false; + cursorGlowSpriteRef.current = glowSprite; + cameraContainer.addChild(glowSprite); + setPixiReady(true); })(); @@ -1062,6 +1108,164 @@ const VideoPlayback = forwardRef( }; }, []); + // ── Cursor overlay (rendered in PixiJS for guaranteed coordinate alignment) ── + const updateCursorGraphics = useCallback(() => { + const gfx = cursorGraphicsRef.current; + const glowSprite = cursorGlowSpriteRef.current; + if (!gfx) return; + + gfx.clear(); + if (glowSprite) glowSprite.visible = false; + + if (!showCursorHighlight || cursorTelemetry.length === 0) return; + + const baseOffset = baseOffsetRef.current; + const scale = baseScaleRef.current; + if (scale === 0) return; + + const samples = cursorTelemetry; + const timeMs = currentTime * 1000; + + // Interpolate position + let cx: number; + let cy: number; + if (samples.length === 1) { + cx = samples[0].cx; + cy = samples[0].cy; + } else if (timeMs <= samples[0].timeMs) { + cx = samples[0].cx; + cy = samples[0].cy; + } else if (timeMs >= samples[samples.length - 1].timeMs) { + cx = samples[samples.length - 1].cx; + cy = samples[samples.length - 1].cy; + } else { + let lo = 0; + let hi = samples.length - 1; + while (lo < hi - 1) { + const mid = Math.floor((lo + hi) / 2); + if (samples[mid].timeMs <= timeMs) lo = mid; + else hi = mid; + } + const a = samples[lo]; + const b = samples[hi]; + const dt = b.timeMs - a.timeMs; + if (dt === 0) { + cx = a.cx; + cy = a.cy; + } else { + const t = Math.max(0, Math.min(1, (timeMs - a.timeMs) / dt)); + cx = a.cx + (b.cx - a.cx) * t; + cy = a.cy + (b.cy - a.cy) * t; + } + } + + // Remap bounds-normalized coords to video-space if display info available + if ( + cursorDisplayInfo && + cursorDisplayInfo.boundsWidth > 0 && + cursorDisplayInfo.boundsHeight > 0 + ) { + const absX = cx * cursorDisplayInfo.boundsWidth + cursorDisplayInfo.boundsX; + const absY = cy * cursorDisplayInfo.boundsHeight + cursorDisplayInfo.boundsY; + const waW = Math.max(1, cursorDisplayInfo.workAreaWidth); + const waH = Math.max(1, cursorDisplayInfo.workAreaHeight); + cx = (absX - cursorDisplayInfo.workAreaX) / waW; + cy = (absY - cursorDisplayInfo.workAreaY) / waH; + } + + // Check if inside crop region + const crop = cropRegion || { x: 0, y: 0, width: 1, height: 1 }; + if (cx < crop.x || cx > crop.x + crop.width || cy < crop.y || cy > crop.y + crop.height) { + return; + } + + // Position in the same coordinate space as the video sprite: + // videoSprite is at (baseOffset.x, baseOffset.y) with scale (baseScale) + // A pixel at normalized (cx, cy) in the video is at: + const videoSprite = videoSpriteRef.current; + if (!videoSprite) return; + const videoW = videoSprite.texture.width; + const videoH = videoSprite.texture.height; + const px = baseOffset.x + cx * videoW * scale; + const py = baseOffset.y + cy * videoH * scale; + + // Draw cursor shape + const sz = cursorSize; + if (cursorStyle === "dot") { + gfx.circle(px, py, sz / 4); + gfx.fill({ color: cursorColor, alpha: cursorOpacity }); + } else if (cursorStyle === "circle") { + gfx.circle(px, py, sz / 3); + gfx.stroke({ color: cursorColor, alpha: cursorOpacity, width: cursorStrokeWidth }); + } else if (cursorStyle === "ring") { + gfx.circle(px, py, cursorStrokeWidth * 1.5); + gfx.fill({ color: cursorColor, alpha: cursorOpacity }); + gfx.circle(px, py, sz / 3); + gfx.stroke({ color: cursorColor, alpha: cursorOpacity, width: cursorStrokeWidth }); + } else if (cursorStyle === "glow" && glowSprite) { + // Use offscreen canvas with real radial gradient for perfectly smooth glow + // Glow renders 1.5x bigger than the size setting for a softer spread + const texSize = Math.max(Math.round(sz * 1.5), 4); + const cache = cursorGlowCacheRef.current; + if ( + !cache || + cache.color !== cursorColor || + cache.size !== texSize || + cache.opacity !== cursorOpacity + ) { + const canvas = document.createElement("canvas"); + canvas.width = texSize; + canvas.height = texSize; + const ctx = canvas.getContext("2d"); + if (ctx) { + const cx = texSize / 2; + const cy = texSize / 2; + const r = texSize / 2; + const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r); + // Use hexToRgba-style parsing (handle 0 values correctly) + const m = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(cursorColor); + const hr = m ? parseInt(m[1], 16) : 255; + const hg = m ? parseInt(m[2], 16) : 204; + const hb = m ? parseInt(m[3], 16) : 0; + // Softer center — peak alpha is 70% of opacity, fades gently + gradient.addColorStop(0, `rgba(${hr},${hg},${hb},${cursorOpacity * 0.7})`); + gradient.addColorStop(0.3, `rgba(${hr},${hg},${hb},${cursorOpacity * 0.4})`); + gradient.addColorStop(0.7, `rgba(${hr},${hg},${hb},${cursorOpacity * 0.12})`); + gradient.addColorStop(1, `rgba(${hr},${hg},${hb},0)`); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, texSize, texSize); + } + const oldTexture = glowSprite.texture; + glowSprite.texture = Texture.from(canvas); + if (oldTexture && oldTexture !== Texture.EMPTY) oldTexture.destroy(true); + cursorGlowCacheRef.current = { + color: cursorColor, + size: texSize, + opacity: cursorOpacity, + }; + } + glowSprite.position.set(px, py); + glowSprite.width = texSize; + glowSprite.height = texSize; + glowSprite.visible = true; + } + }, [ + showCursorHighlight, + cursorTelemetry, + currentTime, + cropRegion, + cursorStyle, + cursorColor, + cursorSize, + cursorOpacity, + cursorStrokeWidth, + cursorDisplayInfo, + ]); + + useEffect(() => { + updateCursorGraphics(); + }, [updateCursorGraphics]); + const isImageUrl = Boolean( resolvedWallpaper && (resolvedWallpaper.startsWith("file://") || @@ -1191,6 +1395,7 @@ const VideoPlayback = forwardRef( /> )); })()} + {/* Cursor is rendered via PixiJS cursorGraphics in cameraContainer */} )}