From ee42a390015cf6516f49aae03fcdbd948f82f9ca Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:46:29 +0800 Subject: [PATCH 01/24] Add global mouse tracking during recording sessions --- dist-electron/main.js | 197 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 23 deletions(-) diff --git a/dist-electron/main.js b/dist-electron/main.js index e303928..f39e835 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -60,13 +60,16 @@ function createHudOverlayWindow() { return win; } function createEditorWindow() { + const isMac = process.platform === "darwin"; const win = new BrowserWindow({ width: 1200, height: 800, minWidth: 800, minHeight: 600, - titleBarStyle: "hiddenInset", - trafficLightPosition: { x: 12, y: 12 }, + ...isMac && { + titleBarStyle: "hiddenInset", + trafficLightPosition: { x: 12, y: 12 } + }, transparent: false, resizable: true, alwaysOnTop: false, @@ -124,6 +127,9 @@ function createSourceSelectorWindow() { return win; } let selectedSource = null; +let globalMouseListenerInterval = null; +let recordingWindow = null; +let lastMousePosition = null; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); @@ -180,6 +186,26 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g }; } }); + ipcMain.handle("store-cursor-data", async (_, videoPath, cursorData) => { + try { + const cursorPath = `${videoPath}.cursor.json`; + const payload = JSON.stringify(cursorData); + await fs.writeFile(cursorPath, payload, "utf-8"); + return { success: true, path: cursorPath }; + } catch (error) { + console.error("Failed to store cursor data:", error); + return { success: false, message: "Failed to store cursor data", error: String(error) }; + } + }); + ipcMain.handle("load-cursor-data", async (_, videoPath) => { + try { + const cursorPath = `${videoPath}.cursor.json`; + const data = await fs.readFile(cursorPath, "utf-8"); + return { success: true, path: cursorPath, data }; + } catch (error) { + return { success: false, message: "Cursor data not found", error: String(error) }; + } + }); ipcMain.handle("get-recorded-video-path", async () => { try { const files = await fs.readdir(RECORDINGS_DIR); @@ -200,7 +226,57 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g if (onRecordingStateChange) { onRecordingStateChange(recording, source.name); } + if (recording) { + startGlobalMouseListener(getMainWindow()); + } else { + stopGlobalMouseListener(); + } }); + function startGlobalMouseListener(window) { + if (globalMouseListenerInterval) { + return; + } + recordingWindow = window; + lastMousePosition = null; + globalMouseListenerInterval = setInterval(() => { + const targetWindow = recordingWindow || getMainWindow(); + if (!targetWindow || targetWindow.isDestroyed()) { + const allWindows = BrowserWindow.getAllWindows(); + if (allWindows.length === 0) { + stopGlobalMouseListener(); + return; + } + recordingWindow = allWindows[0]; + } + try { + const point = screen.getCursorScreenPoint(); + const currentPosition = { x: point.x, y: point.y }; + if (!lastMousePosition || lastMousePosition.x !== currentPosition.x || lastMousePosition.y !== currentPosition.y) { + const windows = BrowserWindow.getAllWindows(); + windows.forEach((win) => { + if (!win.isDestroyed()) { + win.webContents.send("global-mouse-move", { + screenX: currentPosition.x, + screenY: currentPosition.y, + timestamp: Date.now() + }); + } + }); + lastMousePosition = currentPosition; + } + } catch (error) { + console.error("Error in global mouse listener:", error); + } + }, 1e3 / 60); + } + function stopGlobalMouseListener() { + if (globalMouseListenerInterval) { + clearInterval(globalMouseListenerInterval); + globalMouseListenerInterval = null; + } + recordingWindow = null; + lastMousePosition = null; + } ipcMain.handle("open-external-url", async (_, url) => { try { await shell.openExternal(url); @@ -295,6 +371,62 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("get-platform", () => { return process.platform; }); + ipcMain.handle("get-source-bounds", async () => { + try { + if (!selectedSource) { + return { success: false, message: "No source selected" }; + } + const sourceId = selectedSource.id; + if (sourceId.startsWith("screen:")) { + const displays = screen.getAllDisplays(); + const displayId = selectedSource.display_id; + const display = displays.find((d) => String(d.id) === String(displayId)) || screen.getPrimaryDisplay(); + return { + success: true, + bounds: { + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height + }, + scaleFactor: display.scaleFactor || 1 + }; + } + if (sourceId.startsWith("window:")) { + const displays = screen.getAllDisplays(); + const displayId = selectedSource.display_id; + const display = displays.find((d) => String(d.id) === String(displayId)) || screen.getPrimaryDisplay(); + return { + success: true, + bounds: { + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height + }, + scaleFactor: display.scaleFactor || 1 + }; + } + const primaryDisplay = screen.getPrimaryDisplay(); + return { + success: true, + bounds: { + x: primaryDisplay.bounds.x, + y: primaryDisplay.bounds.y, + width: primaryDisplay.bounds.width, + height: primaryDisplay.bounds.height + }, + scaleFactor: primaryDisplay.scaleFactor || 1 + }; + } catch (error) { + console.error("Failed to get source bounds:", error); + return { + success: false, + message: "Failed to get source bounds", + error: String(error) + }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); @@ -316,19 +448,26 @@ let mainWindow = null; let sourceSelectorWindow = null; let tray = null; let selectedSourceName = ""; +const defaultTrayIcon = getTrayIcon("openscreen.png"); +const recordingTrayIcon = getTrayIcon("rec-button.png"); function createWindow() { mainWindow = createHudOverlayWindow(); } function createTray() { - const iconPath = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, "rec-button.png"); - let icon = nativeImage.createFromPath(iconPath); - icon = icon.resize({ width: 24, height: 24, quality: "best" }); - tray = new Tray(icon); - updateTrayMenu(); + tray = new Tray(defaultTrayIcon); +} +function getTrayIcon(filename) { + return nativeImage.createFromPath(path.join(process.env.VITE_PUBLIC || RENDERER_DIST, filename)).resize({ + width: 24, + height: 24, + quality: "best" + }); } -function updateTrayMenu() { +function updateTrayMenu(recording = false) { if (!tray) return; - const menuTemplate = [ + const trayIcon = recording ? recordingTrayIcon : defaultTrayIcon; + const trayToolTip = recording ? `Recording: ${selectedSourceName}` : "OpenScreen"; + const menuTemplate = recording ? [ { label: "Stop Recording", click: () => { @@ -337,10 +476,27 @@ function updateTrayMenu() { } } } + ] : [ + { + label: "Open", + click: () => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.isMinimized() && mainWindow.restore(); + } else { + createWindow(); + } + } + }, + { + label: "Quit", + click: () => { + app.quit(); + } + } ]; - const contextMenu = Menu.buildFromTemplate(menuTemplate); - tray.setContextMenu(contextMenu); - tray.setToolTip(`Recording: ${selectedSourceName}`); + tray.setImage(trayIcon); + tray.setToolTip(trayToolTip); + tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); } function createEditorWindowWrapper() { if (mainWindow) { @@ -366,10 +522,10 @@ app.on("activate", () => { app.whenReady().then(async () => { const { ipcMain: ipcMain2 } = await import("electron"); ipcMain2.on("hud-overlay-close", () => { - if (process.platform === "darwin") { - app.quit(); - } + app.quit(); }); + createTray(); + updateTrayMenu(); await ensureRecordingsDir(); registerIpcHandlers( createEditorWindowWrapper, @@ -378,14 +534,9 @@ app.whenReady().then(async () => { () => sourceSelectorWindow, (recording, sourceName) => { selectedSourceName = sourceName; - if (recording) { - if (!tray) createTray(); - updateTrayMenu(); - } else { - if (tray) { - tray.destroy(); - tray = null; - } + if (!tray) createTray(); + updateTrayMenu(recording); + if (!recording) { if (mainWindow) mainWindow.restore(); } } From 029313962b065d3b4ff14d2b6548019fe038e649 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:46:31 +0800 Subject: [PATCH 02/24] Add cursor and mouse move event handlers in preload --- dist-electron/preload.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index cb59604..f1ee587 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -28,6 +28,12 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { storeRecordedVideo: (videoData, fileName) => { return electron.ipcRenderer.invoke("store-recorded-video", videoData, fileName); }, + storeCursorData: (videoPath, cursorData) => { + return electron.ipcRenderer.invoke("store-cursor-data", videoPath, cursorData); + }, + loadCursorData: (videoPath) => { + return electron.ipcRenderer.invoke("load-cursor-data", videoPath); + }, getRecordedVideoPath: () => { return electron.ipcRenderer.invoke("get-recorded-video-path"); }, @@ -39,6 +45,11 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { electron.ipcRenderer.on("stop-recording-from-tray", listener); return () => electron.ipcRenderer.removeListener("stop-recording-from-tray", listener); }, + onGlobalMouseMove: (callback) => { + const listener = (_, event) => callback(event); + electron.ipcRenderer.on("global-mouse-move", listener); + return () => electron.ipcRenderer.removeListener("global-mouse-move", listener); + }, openExternalUrl: (url) => { return electron.ipcRenderer.invoke("open-external-url", url); }, @@ -59,5 +70,8 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, getPlatform: () => { return electron.ipcRenderer.invoke("get-platform"); + }, + getSourceBounds: () => { + return electron.ipcRenderer.invoke("get-source-bounds"); } }); From ad75a50bea0eb9c1dff312688131886e363eed97 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:46:33 +0800 Subject: [PATCH 03/24] Add cursor data storage and retrieval functions --- electron/electron-env.d.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..e32560d 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -29,8 +29,10 @@ interface Window { openSourceSelector: () => Promise selectSource: (source: any) => Promise getSelectedSource: () => Promise - storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> - getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> + storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }> + storeCursorData: (videoPath: string, cursorData: unknown) => Promise<{ success: boolean; path?: string; message?: string; error?: string }> + loadCursorData: (videoPath: string) => Promise<{ success: boolean; path?: string; data?: string; message?: string; error?: string }> + getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }> setRecordingState: (recording: boolean) => Promise onStopRecordingFromTray: (callback: () => void) => () => void openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }> From 2250eb6fe482a4a83485d74b081570c1422b1254 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:46:35 +0800 Subject: [PATCH 04/24] Add global mouse position tracking during recording --- electron/ipc/handlers.ts | 180 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 179 insertions(+), 1 deletion(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 34c9886..a61b7e2 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,10 +1,13 @@ -import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'electron' +import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog, screen } from 'electron' import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' let selectedSource: any = null +let globalMouseListenerInterval: NodeJS.Timeout | null = null +let recordingWindow: BrowserWindow | null = null +let lastMousePosition: { x: number; y: number } | null = null export function registerIpcHandlers( createEditorWindow: () => void, @@ -76,6 +79,28 @@ export function registerIpcHandlers( } }) + ipcMain.handle('store-cursor-data', async (_, videoPath: string, cursorData: unknown) => { + try { + const cursorPath = `${videoPath}.cursor.json` + const payload = JSON.stringify(cursorData) + await fs.writeFile(cursorPath, payload, 'utf-8') + return { success: true, path: cursorPath } + } catch (error) { + console.error('Failed to store cursor data:', error) + return { success: false, message: 'Failed to store cursor data', error: String(error) } + } + }) + + ipcMain.handle('load-cursor-data', async (_, videoPath: string) => { + try { + const cursorPath = `${videoPath}.cursor.json` + const data = await fs.readFile(cursorPath, 'utf-8') + return { success: true, path: cursorPath, data } + } catch (error) { + return { success: false, message: 'Cursor data not found', error: String(error) } + } + }) + ipcMain.handle('get-recorded-video-path', async () => { @@ -102,8 +127,80 @@ export function registerIpcHandlers( if (onRecordingStateChange) { onRecordingStateChange(recording, source.name) } + + // Start or stop global mouse listener + if (recording) { + startGlobalMouseListener(getMainWindow()) + } else { + stopGlobalMouseListener() + } }) + function startGlobalMouseListener(window: BrowserWindow | null) { + if (globalMouseListenerInterval) { + return // Already running + } + + recordingWindow = window + lastMousePosition = null + + // Poll mouse position and button state at 60fps for smooth cursor tracking + globalMouseListenerInterval = setInterval(() => { + // Find the recording window (could be HUD overlay or editor window) + const targetWindow = recordingWindow || getMainWindow() + + if (!targetWindow || targetWindow.isDestroyed()) { + // Try to find any open window + const allWindows = BrowserWindow.getAllWindows() + if (allWindows.length === 0) { + stopGlobalMouseListener() + return + } + recordingWindow = allWindows[0] + } + + try { + const point = screen.getCursorScreenPoint() + const currentPosition = { x: point.x, y: point.y } + + // Check if position changed + if (!lastMousePosition || + lastMousePosition.x !== currentPosition.x || + lastMousePosition.y !== currentPosition.y) { + + // Send mouse move event to all windows (in case recording is in different window) + const windows = BrowserWindow.getAllWindows() + windows.forEach(win => { + if (!win.isDestroyed()) { + win.webContents.send('global-mouse-move', { + screenX: currentPosition.x, + screenY: currentPosition.y, + timestamp: Date.now() + }) + } + }) + + lastMousePosition = currentPosition + } + + // Note: Electron's screen API doesn't provide mouse button state + // We'll rely on the renderer process to capture button events + // when they occur within the application window + } catch (error) { + console.error('Error in global mouse listener:', error) + } + }, 1000 / 60) // 60fps + } + + function stopGlobalMouseListener() { + if (globalMouseListenerInterval) { + clearInterval(globalMouseListenerInterval) + globalMouseListenerInterval = null + } + recordingWindow = null + lastMousePosition = null + } + ipcMain.handle('open-external-url', async (_, url: string) => { try { @@ -212,4 +309,85 @@ export function registerIpcHandlers( ipcMain.handle('get-platform', () => { return process.platform; }); + + ipcMain.handle('get-source-bounds', async () => { + try { + if (!selectedSource) { + return { success: false, message: 'No source selected' }; + } + + const sourceId = selectedSource.id; + + // Handle screen sources + if (sourceId.startsWith('screen:')) { + const displays = screen.getAllDisplays(); + const displayId = selectedSource.display_id; + + // Find the display matching the display_id + const display = displays.find(d => String(d.id) === String(displayId)) || screen.getPrimaryDisplay(); + + // Use bounds which are in physical pixels (already account for DPI scaling) + return { + success: true, + bounds: { + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height, + }, + scaleFactor: display.scaleFactor || 1.0 + }; + } + + // Handle window sources + if (sourceId.startsWith('window:')) { + // For window sources, we need to get the bounds of the window + // Since desktopCapturer doesn't provide direct window access, + // we'll try to get the display that contains the window + // by getting all windows and matching by name or using a fallback + + // Get all displays to find the one that likely contains this window + const displays = screen.getAllDisplays(); + const displayId = selectedSource.display_id; + + // Try to find the display matching the display_id + const display = displays.find(d => String(d.id) === String(displayId)) || screen.getPrimaryDisplay(); + + // For window sources, we'll use the display bounds as a fallback + // In a more sophisticated implementation, you might want to track + // window positions when sources are selected, but for now this is + // a reasonable approximation + return { + success: true, + bounds: { + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height, + }, + scaleFactor: display.scaleFactor || 1.0 + }; + } + + // Fallback to primary display + const primaryDisplay = screen.getPrimaryDisplay(); + return { + success: true, + bounds: { + x: primaryDisplay.bounds.x, + y: primaryDisplay.bounds.y, + width: primaryDisplay.bounds.width, + height: primaryDisplay.bounds.height, + }, + scaleFactor: primaryDisplay.scaleFactor || 1.0 + }; + } catch (error) { + console.error('Failed to get source bounds:', error); + return { + success: false, + message: 'Failed to get source bounds', + error: String(error) + }; + } + }); } From fb4bff97341e7618d1594ab4c1754199a72d391e Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:46:36 +0800 Subject: [PATCH 05/24] Add cursor data and global mouse move event handling --- electron/preload.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..58960c2 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -30,6 +30,12 @@ contextBridge.exposeInMainWorld('electronAPI', { storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => { return ipcRenderer.invoke('store-recorded-video', videoData, fileName) }, + storeCursorData: (videoPath: string, cursorData: any) => { + return ipcRenderer.invoke('store-cursor-data', videoPath, cursorData) + }, + loadCursorData: (videoPath: string) => { + return ipcRenderer.invoke('load-cursor-data', videoPath) + }, getRecordedVideoPath: () => { return ipcRenderer.invoke('get-recorded-video-path') @@ -42,6 +48,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on('stop-recording-from-tray', listener) return () => ipcRenderer.removeListener('stop-recording-from-tray', listener) }, + onGlobalMouseMove: (callback: (event: { screenX: number; screenY: number; timestamp: number }) => void) => { + const listener = (_: any, event: { screenX: number; screenY: number; timestamp: number }) => callback(event) + ipcRenderer.on('global-mouse-move', listener) + return () => ipcRenderer.removeListener('global-mouse-move', listener) + }, openExternalUrl: (url: string) => { return ipcRenderer.invoke('open-external-url', url) }, @@ -63,4 +74,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, -}) \ No newline at end of file + getSourceBounds: () => { + return ipcRenderer.invoke('get-source-bounds') + }, +}) From e99d37e667f20c0c76498be53ba06c1dfd3f87e4 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:50:57 +0800 Subject: [PATCH 06/24] Add default SVG icon file --- public/default.svg | 1 + 1 file changed, 1 insertion(+) create mode 100644 public/default.svg diff --git a/public/default.svg b/public/default.svg new file mode 100644 index 0000000..42ae068 --- /dev/null +++ b/public/default.svg @@ -0,0 +1 @@ + \ No newline at end of file From fff2b433d66874d408506fd1370546903d8056b4 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:51:00 +0800 Subject: [PATCH 07/24] Add cursor style controls in SettingsPanel component --- src/components/video-editor/SettingsPanel.tsx | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4a9d5f1..f3e7d6e 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -5,11 +5,12 @@ import { Slider } from "@/components/ui/slider"; import { Switch } from "@/components/ui/switch"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useState } from "react"; import Block from '@uiw/react-color-block'; import { Trash2, Download, Crop, X, Bug, Upload, Star } from "lucide-react"; import { toast } from "sonner"; -import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types"; +import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, CursorTrack, CursorStyle } from "./types"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; @@ -78,6 +79,9 @@ interface SettingsPanelProps { onAnnotationStyleChange?: (id: string, style: Partial) => void; onAnnotationFigureDataChange?: (id: string, figureData: any) => void; onAnnotationDelete?: (id: string) => void; + cursorTrack?: CursorTrack | null; + selectedCursorId?: string | null; + onCursorStyleChange?: (style: Partial) => void; } export default SettingsPanel; @@ -124,6 +128,9 @@ export function SettingsPanel({ onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDelete, + cursorTrack, + selectedCursorId, + onCursorStyleChange, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); @@ -153,6 +160,7 @@ export function SettingsPanel({ const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); + const cursorEnabled = Boolean(selectedCursorId && cursorTrack && cursorTrack.events.length > 0); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -234,6 +242,48 @@ export function SettingsPanel({ return (
+ {cursorEnabled && cursorTrack && onCursorStyleChange && ( +
+
+ Cursor + + Active + +
+
+
+
+
Size
+ {Math.round(cursorTrack.style.sizePx)}px +
+ onCursorStyleChange({ sizePx: values[0] })} + min={8} + max={48} + step={1} + className="w-full [&_[role=slider]]:bg-[#4C8BF5] [&_[role=slider]]:border-[#4C8BF5]" + /> +
+
+
Style
+ +
+
+
+ )}
Zoom Level From 056c7cff0a943d7edb6a04af3c9720abeb67e9d8 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:51:02 +0800 Subject: [PATCH 08/24] Adds cursor track loading and selection management functionality --- src/components/video-editor/VideoEditor.tsx | 80 ++++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index eece277..39b279c 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -20,6 +20,7 @@ import { DEFAULT_ANNOTATION_SIZE, DEFAULT_ANNOTATION_STYLE, DEFAULT_FIGURE_DATA, + DEFAULT_CURSOR_STYLE, type ZoomDepth, type ZoomFocus, type ZoomRegion, @@ -27,6 +28,8 @@ import { type AnnotationRegion, type CropRegion, type FigureData, + type CursorTrack, + type CursorStyle, } from "./types"; import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; @@ -37,6 +40,7 @@ const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wall export default function VideoEditor() { const [videoPath, setVideoPath] = useState(null); + const [videoFilePath, setVideoFilePath] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [isPlaying, setIsPlaying] = useState(false); @@ -55,6 +59,8 @@ export default function VideoEditor() { const [selectedTrimId, setSelectedTrimId] = useState(null); const [annotationRegions, setAnnotationRegions] = useState([]); const [selectedAnnotationId, setSelectedAnnotationId] = useState(null); + const [cursorTrack, setCursorTrack] = useState(null); + const [selectedCursorId, setSelectedCursorId] = useState(null); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -93,6 +99,7 @@ export default function VideoEditor() { if (result.success && result.path) { const videoUrl = toFileUrl(result.path); setVideoPath(videoUrl); + setVideoFilePath(result.path); } else { setError('No video to load. Please record or select a video.'); } @@ -105,6 +112,45 @@ export default function VideoEditor() { loadVideo(); }, []); + useEffect(() => { + if (!videoFilePath) { + setCursorTrack(null); + setSelectedCursorId(null); + return; + } + + let mounted = true; + + (async () => { + try { + const result = await window.electronAPI.loadCursorData(videoFilePath); + if (!mounted) return; + if (!result.success || !result.data) { + setCursorTrack(null); + return; + } + const parsed = JSON.parse(result.data); + const events = Array.isArray(parsed?.events) ? parsed.events : []; + const style = parsed?.style ?? {}; + const preset = style.preset === 'arrow' || style.preset === 'dot' || style.preset === 'circle' + ? style.preset + : DEFAULT_CURSOR_STYLE.preset; + const sizePx = typeof style.sizePx === 'number' && Number.isFinite(style.sizePx) + ? style.sizePx + : DEFAULT_CURSOR_STYLE.sizePx; + setCursorTrack({ events, style: { preset, sizePx } }); + } catch (err) { + if (mounted) { + setCursorTrack(null); + } + } + })(); + + return () => { + mounted = false; + }; + }, [videoFilePath]); + // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; @@ -143,6 +189,7 @@ export default function VideoEditor() { const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); if (id) setSelectedTrimId(null); + if (id) setSelectedCursorId(null); }, []); const handleSelectTrim = useCallback((id: string | null) => { @@ -150,6 +197,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedCursorId(null); } }, []); @@ -158,6 +206,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedCursorId(null); } }, []); @@ -382,6 +431,22 @@ export default function VideoEditor() { ), ); }, []); + + const handleSelectCursor = useCallback((id: string | null) => { + setSelectedCursorId(id); + if (id) { + setSelectedZoomId(null); + setSelectedTrimId(null); + setSelectedAnnotationId(null); + } + }, []); + + const handleCursorStyleChange = useCallback((style: Partial) => { + setCursorTrack((prev) => { + if (!prev) return prev; + return { ...prev, style: { ...prev.style, ...style } }; + }); + }, []); // Global Tab prevention useEffect(() => { @@ -434,6 +499,12 @@ export default function VideoEditor() { } }, [selectedAnnotationId, annotationRegions]); + useEffect(() => { + if (selectedCursorId && (!cursorTrack || cursorTrack.events.length === 0)) { + setSelectedCursorId(null); + } + }, [selectedCursorId, cursorTrack]); + const handleExport = useCallback(async () => { if (!videoPath) { toast.error('No video loaded'); @@ -690,6 +761,7 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTrack={cursorTrack} />
@@ -737,6 +809,9 @@ export default function VideoEditor() { onAnnotationDelete={handleAnnotationDelete} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} + cursorTrack={cursorTrack} + selectedCursorId={selectedCursorId} + onSelectCursor={handleSelectCursor} aspectRatio={aspectRatio} onAspectRatioChange={setAspectRatio} /> @@ -779,6 +854,9 @@ export default function VideoEditor() { onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} + cursorTrack={cursorTrack} + selectedCursorId={selectedCursorId} + onCursorStyleChange={handleCursorStyleChange} />
@@ -794,4 +872,4 @@ export default function VideoEditor() { /> ); -} \ No newline at end of file +} From 9232adf3047879876c5ae1a5e593cb93f36cf6e4 Mon Sep 17 00:00:00 2001 From: bakaECC <1064071566@qq.com> Date: Mon, 22 Dec 2025 10:51:05 +0800 Subject: [PATCH 09/24] Add cursor trail rendering with SVG arrow support --- src/components/video-editor/VideoPlayback.tsx | 320 +++++++++++++++++- 1 file changed, 319 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 357adc9..6b0c10c 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -2,7 +2,7 @@ import type React from "react"; import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react"; import { getAssetPath } from "@/lib/assetPath"; import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js'; -import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types"; +import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion, type CursorTrack } from "./types"; import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants"; import { clamp01 } from "./videoPlayback/mathUtils"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; @@ -41,6 +41,7 @@ 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; + cursorTrack?: CursorTrack | null; } export interface VideoPlaybackRef { @@ -80,6 +81,7 @@ const VideoPlayback = forwardRef(({ onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + cursorTrack, }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); @@ -92,6 +94,8 @@ const VideoPlayback = forwardRef(({ const [videoReady, setVideoReady] = useState(false); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); + const cursorCanvasRef = useRef(null); + const cursorImageRef = useRef(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); const selectedZoomIdRef = useRef(null); @@ -114,6 +118,154 @@ const VideoPlayback = forwardRef(({ const motionBlurEnabledRef = useRef(motionBlurEnabled); const videoReadyRafRef = useRef(null); + const CURSOR_TRAIL_MS = 500; + const CURSOR_CLICK_MS = 280; + + // Load default cursor SVG image + useEffect(() => { + const img = new Image(); + img.onload = () => { + cursorImageRef.current = img; + }; + img.onerror = () => { + console.warn('Failed to load default cursor SVG'); + }; + img.src = '/default.svg'; + }, []); + + const resizeCursorCanvas = useCallback(() => { + const overlayEl = overlayRef.current; + const canvas = cursorCanvasRef.current; + if (!overlayEl || !canvas) return; + const width = overlayEl.clientWidth; + const height = overlayEl.clientHeight; + if (!width || !height) return; + + const dpr = window.devicePixelRatio || 1; + const nextWidth = Math.max(1, Math.floor(width * dpr)); + const nextHeight = Math.max(1, Math.floor(height * dpr)); + if (canvas.width !== nextWidth || canvas.height !== nextHeight) { + canvas.width = nextWidth; + canvas.height = nextHeight; + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + } + }, []); + + const findFirstIndex = (events: CursorTrack['events'], tMs: number) => { + let lo = 0; + let hi = events.length; + while (lo < hi) { + const mid = Math.floor((lo + hi) / 2); + if (events[mid].tMs < tMs) { + lo = mid + 1; + } else { + hi = mid; + } + } + return lo; + }; + + const findLastIndex = (events: CursorTrack['events'], tMs: number) => { + let lo = 0; + let hi = events.length - 1; + let best = -1; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + if (events[mid].tMs <= tMs) { + best = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return best; + }; + + const drawArrowCursor = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, fill: string, stroke: string) => { + const w = size * 0.6; + const h = size * 1.2; + ctx.save(); + ctx.translate(x, y); + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(w, h); + ctx.lineTo(w * 0.55, h); + ctx.lineTo(w * 0.9, h * 1.55); + ctx.lineTo(w * 0.6, h * 1.65); + ctx.lineTo(w * 0.25, h * 1.05); + ctx.lineTo(0, h * 1.35); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1, size * 0.08); + ctx.stroke(); + ctx.restore(); + }; + + const drawCursor = ( + ctx: CanvasRenderingContext2D, + preset: CursorTrack['style']['preset'], + x: number, + y: number, + size: number, + dragging: boolean, + ) => { + const fill = 'rgba(255,255,255,0.95)'; + const stroke = 'rgba(0,0,0,0.5)'; + const dragAccent = 'rgba(52,178,123,0.9)'; + + if (dragging) { + ctx.beginPath(); + ctx.strokeStyle = dragAccent; + ctx.lineWidth = Math.max(2, size * 0.15); + ctx.arc(x, y, size * 0.85, 0, Math.PI * 2); + ctx.stroke(); + } + + if (preset === 'dot') { + ctx.beginPath(); + ctx.fillStyle = fill; + ctx.arc(x, y, size * 0.35, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = stroke; + ctx.lineWidth = Math.max(1, size * 0.08); + ctx.stroke(); + return; + } + + if (preset === 'circle') { + ctx.beginPath(); + ctx.strokeStyle = fill; + ctx.lineWidth = Math.max(2, size * 0.12); + ctx.arc(x, y, size * 0.5, 0, Math.PI * 2); + ctx.stroke(); + if (dragging) { + ctx.beginPath(); + ctx.strokeStyle = dragAccent; + ctx.lineWidth = Math.max(1, size * 0.08); + ctx.arc(x, y, size * 0.75, 0, Math.PI * 2); + ctx.stroke(); + } + return; + } + + // Use SVG image for arrow preset + const img = cursorImageRef.current; + if (img && img.complete && img.naturalWidth > 0) { + ctx.save(); + const scale = size / 32; // SVG is 32x32, scale to desired size + ctx.translate(x, y); + ctx.scale(scale, scale); + ctx.drawImage(img, -16, -16); // Center the image (32/2 = 16) + ctx.restore(); + } else { + // Fallback to drawn arrow if image not loaded yet + drawArrowCursor(ctx, x, y, size, fill, stroke); + } + }; + const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); }, []); @@ -432,6 +584,171 @@ const VideoPlayback = forwardRef(({ overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto'; }, [selectedZoom, isPlaying]); + useEffect(() => { + if (!pixiReady || !videoReady) return; + const overlayEl = overlayRef.current; + if (!overlayEl) return; + + resizeCursorCanvas(); + + if (typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => { + resizeCursorCanvas(); + }); + observer.observe(overlayEl); + return () => { + observer.disconnect(); + }; + }, [pixiReady, videoReady, resizeCursorCanvas]); + + useEffect(() => { + if (!pixiReady || !videoReady) return; + const overlayEl = overlayRef.current; + const canvas = cursorCanvasRef.current; + if (!overlayEl || !canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + resizeCursorCanvas(); + + const width = overlayEl.clientWidth; + const height = overlayEl.clientHeight; + if (!width || !height) return; + + const dpr = window.devicePixelRatio || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, width, height); + + if (!cursorTrack || cursorTrack.events.length === 0) { + return; + } + + // Get layout information to properly map cursor coordinates + const maskRect = baseMaskRef.current; + const baseScale = baseScaleRef.current; + const videoSize = videoSizeRef.current; + const cropBounds = cropBoundsRef.current; + + // Calculate the actual video display area (maskRect) + // If maskRect is not initialized yet, fall back to full overlay + const displayArea = maskRect.width > 0 && maskRect.height > 0 + ? maskRect + : { x: 0, y: 0, width, height }; + + const events = cursorTrack.events; + const playheadMs = Math.round(currentTime * 1000); + const lastIndex = findLastIndex(events, playheadMs); + if (lastIndex < 0) return; + + // Helper function to convert normalized video coordinates to display coordinates + // Normalized coordinates (nx, ny) are relative to the full video dimensions (before crop) + const normalizeToDisplay = (nx: number, ny: number) => { + // Get the locked video dimensions (full video size) + const lockedDims = lockedVideoDimensionsRef.current; + if (!lockedDims || lockedDims.width === 0 || lockedDims.height === 0) { + // Fallback: map directly to display area + const displayX = displayArea.x + nx * displayArea.width; + const displayY = displayArea.y + ny * displayArea.height; + return { x: displayX, y: displayY }; + } + + const fullVideoWidth = lockedDims.width; + const fullVideoHeight = lockedDims.height; + + // Convert normalized coordinates to video pixel coordinates (full video) + const videoX = nx * fullVideoWidth; + const videoY = ny * fullVideoHeight; + + // Check if coordinate is within crop bounds (if crop exists) + if (cropBounds.endX > cropBounds.startX && cropBounds.endY > cropBounds.startY) { + if (videoX < cropBounds.startX || videoX > cropBounds.endX || + videoY < cropBounds.startY || videoY > cropBounds.endY) { + // Coordinate is outside crop bounds, don't display + return null; + } + + // Convert to cropped video coordinates (0-1 relative to cropped area) + const croppedX = (videoX - cropBounds.startX) / (cropBounds.endX - cropBounds.startX); + const croppedY = (videoY - cropBounds.startY) / (cropBounds.endY - cropBounds.startY); + + // Map to display coordinates within maskRect (which represents the cropped and scaled display area) + const displayX = displayArea.x + croppedX * displayArea.width; + const displayY = displayArea.y + croppedY * displayArea.height; + + return { x: displayX, y: displayY }; + } else { + // No crop, map directly to display area + const displayX = displayArea.x + nx * displayArea.width; + const displayY = displayArea.y + ny * displayArea.height; + return { x: displayX, y: displayY }; + } + }; + + const currentEvent = events[lastIndex]; + const displayPos = normalizeToDisplay(currentEvent.nx, currentEvent.ny); + if (!displayPos) return; // Coordinate is outside visible area + + const x = displayPos.x; + const y = displayPos.y; + const dragging = currentEvent.dragging; + const baseSize = Math.max(6, cursorTrack.style.sizePx); + const cursorSize = dragging ? baseSize * 1.1 : baseSize; + + const trailStartMs = Math.max(0, playheadMs - CURSOR_TRAIL_MS); + const trailStartIndex = Math.min(lastIndex, findFirstIndex(events, trailStartMs)); + + if (lastIndex - trailStartIndex >= 1) { + ctx.beginPath(); + let pathStarted = false; + for (let i = trailStartIndex; i <= lastIndex; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; // Skip points outside visible area + + if (!pathStarted) { + ctx.moveTo(pos.x, pos.y); + pathStarted = true; + } else { + ctx.lineTo(pos.x, pos.y); + } + } + if (pathStarted) { + ctx.strokeStyle = dragging ? 'rgba(52,178,123,0.55)' : 'rgba(255,255,255,0.35)'; + ctx.lineWidth = Math.max(1, baseSize * 0.12); + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + } + + if (CURSOR_CLICK_MS > 0) { + const clickStartIndex = trailStartIndex; + for (let i = clickStartIndex; i <= lastIndex; i += 1) { + const ev = events[i]; + if (ev.kind !== 'down') continue; + const elapsed = playheadMs - ev.tMs; + if (elapsed < 0 || elapsed > CURSOR_CLICK_MS) continue; + const progress = elapsed / CURSOR_CLICK_MS; + const alpha = 1 - progress; + const radius = baseSize * (0.5 + progress * 1.6); + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; // Skip clicks outside visible area + + ctx.beginPath(); + ctx.strokeStyle = `rgba(255,255,255,${alpha * 0.7})`; + ctx.lineWidth = Math.max(1, baseSize * 0.08); + ctx.arc(pos.x, pos.y, radius, 0, Math.PI * 2); + ctx.stroke(); + } + } + + drawCursor(ctx, cursorTrack.style.preset, x, y, cursorSize, dragging); + }, [pixiReady, videoReady, currentTime, cursorTrack, CURSOR_TRAIL_MS, CURSOR_CLICK_MS, resizeCursorCanvas, cropRegion, padding]); + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -867,6 +1184,7 @@ const VideoPlayback = forwardRef(({ /> )); })()} + )}