diff --git a/dist-electron/main.js b/dist-electron/main.js index e303928..d240a88 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -1,7 +1,11 @@ +var __defProp = Object.defineProperty; +var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; +var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); import { ipcMain, screen, BrowserWindow, desktopCapturer, shell, app, dialog, nativeImage, Tray, Menu } from "electron"; import { fileURLToPath } from "node:url"; import path from "node:path"; import fs from "node:fs/promises"; +import { uIOhook } from "uiohook-napi"; const __dirname$1 = path.dirname(fileURLToPath(import.meta.url)); const APP_ROOT = path.join(__dirname$1, ".."); const VITE_DEV_SERVER_URL$1 = process.env["VITE_DEV_SERVER_URL"]; @@ -60,13 +64,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, @@ -123,7 +130,171 @@ function createSourceSelectorWindow() { } return win; } +class MouseTracker { + constructor() { + __publicField(this, "isTracking", false); + __publicField(this, "events", []); + __publicField(this, "config", null); + __publicField(this, "displayBounds", null); + __publicField(this, "lastMoveTime", 0); + __publicField(this, "moveThrottleMs", 8); + /** + * Handle mouse move events (throttled) + */ + __publicField(this, "handleMouseMove", (e) => { + var _a, _b; + if (!this.isTracking || !this.config) return; + const now = Date.now(); + if (now - this.lastMoveTime < this.moveThrottleMs) { + return; + } + this.lastMoveTime = now; + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y); + const event = { + type: "move", + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY + }; + this.events.push(event); + (_b = (_a = this.config).onCursorEvent) == null ? void 0 : _b.call(_a, event); + }); + /** + * Handle mouse click events + */ + __publicField(this, "handleMouseDown", (e) => { + var _a, _b; + if (!this.isTracking || !this.config) return; + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y); + const event = { + type: "click", + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY, + button: typeof e.button === "number" ? e.button : void 0 + }; + this.events.push(event); + (_b = (_a = this.config).onCursorEvent) == null ? void 0 : _b.call(_a, event); + }); + /** + * Handle scroll wheel events + */ + __publicField(this, "handleWheel", (e) => { + var _a, _b; + if (!this.isTracking || !this.config) return; + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y); + const event = { + type: "scroll", + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY, + scrollDelta: e.rotation + }; + this.events.push(event); + (_b = (_a = this.config).onCursorEvent) == null ? void 0 : _b.call(_a, event); + }); + } + // ~120fps for ultra-smooth cursor tracking + /** + * Start tracking mouse events + */ + start(config) { + if (this.isTracking) { + console.warn("Mouse tracker is already running"); + return; + } + this.config = config; + this.events = []; + this.isTracking = true; + this.displayBounds = this.getDisplayBoundsForSource(config.sourceId); + uIOhook.on("mousemove", this.handleMouseMove); + uIOhook.on("mousedown", this.handleMouseDown); + uIOhook.on("wheel", this.handleWheel); + uIOhook.start(); + console.log("Mouse tracker started for source:", config.sourceId); + } + /** + * Stop tracking and return all captured events + */ + stop() { + if (!this.isTracking) { + return []; + } + uIOhook.off("mousemove", this.handleMouseMove); + uIOhook.off("mousedown", this.handleMouseDown); + uIOhook.off("wheel", this.handleWheel); + try { + uIOhook.stop(); + } catch (e) { + console.error("Error stopping uIOhook:", e); + } + this.isTracking = false; + const capturedEvents = [...this.events]; + this.events = []; + this.config = null; + this.displayBounds = null; + console.log(`Mouse tracker stopped. Captured ${capturedEvents.length} events`); + return capturedEvents; + } + /** + * Get the current events without stopping + */ + getEvents() { + return [...this.events]; + } + /** + * Check if tracker is running + */ + isRunning() { + return this.isTracking; + } + /** + * Get display bounds for the given source ID + */ + getDisplayBoundsForSource(sourceId) { + const displays = screen.getAllDisplays(); + if (sourceId.startsWith("screen:")) { + const parts = sourceId.split(":"); + if (parts.length >= 2) { + const displayIndex = parseInt(parts[1], 10); + const display = displays[displayIndex] || screen.getPrimaryDisplay(); + return display.bounds; + } + } + const primaryDisplay = screen.getPrimaryDisplay(); + return primaryDisplay.bounds; + } + /** + * Normalize coordinates to 0-1 range based on display bounds + */ + normalizeCoordinates(x, y) { + if (!this.displayBounds) { + return { normalizedX: 0.5, normalizedY: 0.5 }; + } + const { x: bx, y: by, width, height } = this.displayBounds; + const clampedX = Math.max(bx, Math.min(x, bx + width)); + const clampedY = Math.max(by, Math.min(y, by + height)); + const normalizedX = (clampedX - bx) / width; + const normalizedY = (clampedY - by) / height; + return { normalizedX, normalizedY }; + } + /** + * Calculate timestamp relative to recording start + */ + getTimestamp() { + if (!this.config) return 0; + return Date.now() - this.config.recordingStartTime; + } +} +const mouseTracker = new MouseTracker(); let selectedSource = null; +let currentCursorEvents = []; function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, getMainWindow, getSourceSelectorWindow, onRecordingStateChange) { ipcMain.handle("get-sources", async (_, opts) => { const sources = await desktopCapturer.getSources(opts); @@ -295,6 +466,57 @@ function registerIpcHandlers(createEditorWindow2, createSourceSelectorWindow2, g ipcMain.handle("get-platform", () => { return process.platform; }); + ipcMain.handle("start-mouse-tracking", (_, sourceId, recordingStartTime) => { + try { + currentCursorEvents = []; + mouseTracker.start({ + sourceId, + recordingStartTime, + onCursorEvent: (event) => { + currentCursorEvents.push(event); + } + }); + return { success: true }; + } catch (error) { + console.error("Failed to start mouse tracking:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("stop-mouse-tracking", () => { + try { + const events = mouseTracker.stop(); + currentCursorEvents = events; + return { success: true, events }; + } catch (error) { + console.error("Failed to stop mouse tracking:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("get-cursor-events", () => { + return { success: true, events: currentCursorEvents }; + }); + ipcMain.handle("store-cursor-events", async (_, events, videoFileName) => { + try { + const eventsFileName = videoFileName.replace(/\.(webm|mp4)$/, "-cursor-events.json"); + const eventsPath = path.join(RECORDINGS_DIR, eventsFileName); + await fs.writeFile(eventsPath, JSON.stringify(events, null, 2)); + return { success: true, path: eventsPath }; + } catch (error) { + console.error("Failed to store cursor events:", error); + return { success: false, error: String(error) }; + } + }); + ipcMain.handle("load-cursor-events", async (_, videoPath) => { + try { + const eventsPath = videoPath.replace(/\.(webm|mp4)$/, "-cursor-events.json"); + const data = await fs.readFile(eventsPath, "utf-8"); + const events = JSON.parse(data); + return { success: true, events }; + } catch (error) { + console.log("No cursor events found for video:", videoPath); + return { success: false, events: [] }; + } + }); } const __dirname = path.dirname(fileURLToPath(import.meta.url)); const RECORDINGS_DIR = path.join(app.getPath("userData"), "recordings"); @@ -366,9 +588,7 @@ 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(); }); await ensureRecordingsDir(); registerIpcHandlers( diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index cb59604..8dc97a2 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -59,5 +59,21 @@ electron.contextBridge.exposeInMainWorld("electronAPI", { }, getPlatform: () => { return electron.ipcRenderer.invoke("get-platform"); + }, + // Mouse tracking APIs for auto-zoom feature + startMouseTracking: (sourceId, recordingStartTime) => { + return electron.ipcRenderer.invoke("start-mouse-tracking", sourceId, recordingStartTime); + }, + stopMouseTracking: () => { + return electron.ipcRenderer.invoke("stop-mouse-tracking"); + }, + getCursorEvents: () => { + return electron.ipcRenderer.invoke("get-cursor-events"); + }, + storeCursorEvents: (events, videoFileName) => { + return electron.ipcRenderer.invoke("store-cursor-events", events, videoFileName); + }, + loadCursorEvents: (videoPath) => { + return electron.ipcRenderer.invoke("load-cursor-events", videoPath); } }); diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index dba3f16..b39145b 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -21,6 +21,18 @@ declare namespace NodeJS { } } +// Cursor event types for auto-zoom feature +interface CursorEvent { + type: 'move' | 'click' | 'scroll' + timestamp: number // ms since recording start + x: number // screen x coordinate + y: number // screen y coordinate + normalizedX: number // 0-1 normalized to recorded display + normalizedY: number // 0-1 normalized to recorded display + button?: number // 1 = left, 2 = right, 3 = middle + scrollDelta?: number // for scroll events +} + // Used in Renderer process, expose in `preload.ts` interface Window { electronAPI: { @@ -42,6 +54,13 @@ interface Window { getPlatform: () => Promise hudOverlayHide: () => void; hudOverlayClose: () => void; + getAssetBasePath: () => Promise + // Mouse tracking APIs for auto-zoom feature + startMouseTracking: (sourceId: string, recordingStartTime: number) => Promise<{ success: boolean; error?: string }> + stopMouseTracking: () => Promise<{ success: boolean; events?: CursorEvent[]; error?: string }> + getCursorEvents: () => Promise<{ success: boolean; events: CursorEvent[] }> + storeCursorEvents: (events: CursorEvent[], videoFileName: string) => Promise<{ success: boolean; path?: string; error?: string }> + loadCursorEvents: (videoPath: string) => Promise<{ success: boolean; events: CursorEvent[] }> } } @@ -51,4 +70,4 @@ interface ProcessedDesktopSource { display_id: string thumbnail: string | null appIcon: string | null -} +} \ No newline at end of file diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 34c9886..0ed42f5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3,8 +3,10 @@ import { ipcMain, desktopCapturer, BrowserWindow, shell, app, dialog } from 'ele import fs from 'node:fs/promises' import path from 'node:path' import { RECORDINGS_DIR } from '../main' +import { mouseTracker, type CursorEvent } from '../mouse-tracker' let selectedSource: any = null +let currentCursorEvents: CursorEvent[] = [] export function registerIpcHandlers( createEditorWindow: () => void, @@ -212,4 +214,64 @@ export function registerIpcHandlers( ipcMain.handle('get-platform', () => { return process.platform; }); + + // Mouse tracking IPC handlers for auto-zoom feature + ipcMain.handle('start-mouse-tracking', (_, sourceId: string, recordingStartTime: number) => { + try { + currentCursorEvents = [] + mouseTracker.start({ + sourceId, + recordingStartTime, + onCursorEvent: (event) => { + currentCursorEvents.push(event) + } + }) + return { success: true } + } catch (error) { + console.error('Failed to start mouse tracking:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('stop-mouse-tracking', () => { + try { + const events = mouseTracker.stop() + currentCursorEvents = events + return { success: true, events } + } catch (error) { + console.error('Failed to stop mouse tracking:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('get-cursor-events', () => { + return { success: true, events: currentCursorEvents } + }) + + ipcMain.handle('store-cursor-events', async (_, events: CursorEvent[], videoFileName: string) => { + try { + // Store cursor events as JSON alongside the video file + const eventsFileName = videoFileName.replace(/\.(webm|mp4)$/, '-cursor-events.json') + const eventsPath = path.join(RECORDINGS_DIR, eventsFileName) + await fs.writeFile(eventsPath, JSON.stringify(events, null, 2)) + return { success: true, path: eventsPath } + } catch (error) { + console.error('Failed to store cursor events:', error) + return { success: false, error: String(error) } + } + }) + + ipcMain.handle('load-cursor-events', async (_, videoPath: string) => { + try { + // Load cursor events JSON for a video file + const eventsPath = videoPath.replace(/\.(webm|mp4)$/, '-cursor-events.json') + const data = await fs.readFile(eventsPath, 'utf-8') + const events = JSON.parse(data) as CursorEvent[] + return { success: true, events } + } catch (error) { + // It's OK if there are no cursor events (e.g., for imported videos) + console.log('No cursor events found for video:', videoPath) + return { success: false, events: [] } + } + }) } diff --git a/electron/mouse-tracker.ts b/electron/mouse-tracker.ts new file mode 100644 index 0000000..e9093ac --- /dev/null +++ b/electron/mouse-tracker.ts @@ -0,0 +1,230 @@ +import { uIOhook, UiohookMouseEvent, UiohookWheelEvent } from 'uiohook-napi' +import { screen } from 'electron' + +/** + * Represents a cursor event captured during recording + */ +export interface CursorEvent { + type: 'move' | 'click' | 'scroll' + timestamp: number // ms since recording start + x: number // screen x coordinate + y: number // screen y coordinate + normalizedX: number // 0-1 normalized to recorded display + normalizedY: number // 0-1 normalized to recorded display + button?: number // 1 = left, 2 = right, 3 = middle + scrollDelta?: number // for scroll events +} + +/** + * Configuration for the mouse tracker + */ +export interface MouseTrackerConfig { + sourceId: string // The Electron desktopCapturer source ID being recorded + recordingStartTime: number + onCursorEvent?: (event: CursorEvent) => void +} + +class MouseTracker { + private isTracking = false + private events: CursorEvent[] = [] + private config: MouseTrackerConfig | null = null + private displayBounds: { x: number; y: number; width: number; height: number } | null = null + private lastMoveTime = 0 + private moveThrottleMs = 8 // ~120fps for ultra-smooth cursor tracking + + /** + * Start tracking mouse events + */ + start(config: MouseTrackerConfig): void { + if (this.isTracking) { + console.warn('Mouse tracker is already running') + return + } + + this.config = config + this.events = [] + this.isTracking = true + + // Determine display bounds based on source ID + this.displayBounds = this.getDisplayBoundsForSource(config.sourceId) + + // Register event handlers + uIOhook.on('mousemove', this.handleMouseMove) + uIOhook.on('mousedown', this.handleMouseDown) + uIOhook.on('wheel', this.handleWheel) + + // Start the hook + uIOhook.start() + + console.log('Mouse tracker started for source:', config.sourceId) + } + + /** + * Stop tracking and return all captured events + */ + stop(): CursorEvent[] { + if (!this.isTracking) { + return [] + } + + uIOhook.off('mousemove', this.handleMouseMove) + uIOhook.off('mousedown', this.handleMouseDown) + uIOhook.off('wheel', this.handleWheel) + + try { + uIOhook.stop() + } catch (e) { + console.error('Error stopping uIOhook:', e) + } + + this.isTracking = false + const capturedEvents = [...this.events] + this.events = [] + this.config = null + this.displayBounds = null + + console.log(`Mouse tracker stopped. Captured ${capturedEvents.length} events`) + return capturedEvents + } + + /** + * Get the current events without stopping + */ + getEvents(): CursorEvent[] { + return [...this.events] + } + + /** + * Check if tracker is running + */ + isRunning(): boolean { + return this.isTracking + } + + /** + * Get display bounds for the given source ID + */ + private getDisplayBoundsForSource(sourceId: string): { x: number; y: number; width: number; height: number } { + const displays = screen.getAllDisplays() + + // For screen sources (e.g., "screen:0:0"), parse the display ID + if (sourceId.startsWith('screen:')) { + const parts = sourceId.split(':') + if (parts.length >= 2) { + const displayIndex = parseInt(parts[1], 10) + const display = displays[displayIndex] || screen.getPrimaryDisplay() + return display.bounds + } + } + + // For window sources or fallback, use primary display + // Window sources capture relative to the window, but we track global cursor + const primaryDisplay = screen.getPrimaryDisplay() + + // For windows, we still track the whole screen but normalize to primary + // The video editor will need to know this was a window capture + return primaryDisplay.bounds + } + + /** + * Normalize coordinates to 0-1 range based on display bounds + */ + private normalizeCoordinates(x: number, y: number): { normalizedX: number; normalizedY: number } { + if (!this.displayBounds) { + return { normalizedX: 0.5, normalizedY: 0.5 } + } + + const { x: bx, y: by, width, height } = this.displayBounds + + // Clamp coordinates within display bounds + const clampedX = Math.max(bx, Math.min(x, bx + width)) + const clampedY = Math.max(by, Math.min(y, by + height)) + + const normalizedX = (clampedX - bx) / width + const normalizedY = (clampedY - by) / height + + return { normalizedX, normalizedY } + } + + /** + * Calculate timestamp relative to recording start + */ + private getTimestamp(): number { + if (!this.config) return 0 + return Date.now() - this.config.recordingStartTime + } + + /** + * Handle mouse move events (throttled) + */ + private handleMouseMove = (e: UiohookMouseEvent): void => { + if (!this.isTracking || !this.config) return + + const now = Date.now() + if (now - this.lastMoveTime < this.moveThrottleMs) { + return // Throttle move events + } + this.lastMoveTime = now + + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y) + + const event: CursorEvent = { + type: 'move', + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY, + } + + this.events.push(event) + this.config.onCursorEvent?.(event) + } + + /** + * Handle mouse click events + */ + private handleMouseDown = (e: UiohookMouseEvent): void => { + if (!this.isTracking || !this.config) return + + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y) + + const event: CursorEvent = { + type: 'click', + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY, + button: typeof e.button === 'number' ? e.button : undefined, + } + + this.events.push(event) + this.config.onCursorEvent?.(event) + } + + /** + * Handle scroll wheel events + */ + private handleWheel = (e: UiohookWheelEvent): void => { + if (!this.isTracking || !this.config) return + + const { normalizedX, normalizedY } = this.normalizeCoordinates(e.x, e.y) + + const event: CursorEvent = { + type: 'scroll', + timestamp: this.getTimestamp(), + x: e.x, + y: e.y, + normalizedX, + normalizedY, + scrollDelta: e.rotation, + } + + this.events.push(event) + this.config.onCursorEvent?.(event) + } +} + +// Export singleton instance +export const mouseTracker = new MouseTracker() diff --git a/electron/preload.ts b/electron/preload.ts index 02fcc97..b918bac 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -1,5 +1,17 @@ import { contextBridge, ipcRenderer } from 'electron' +// Type for cursor events from mouse tracker +interface CursorEvent { + type: 'move' | 'click' | 'scroll' + timestamp: number + x: number + y: number + normalizedX: number + normalizedY: number + button?: number + scrollDelta?: number +} + contextBridge.exposeInMainWorld('electronAPI', { hudOverlayHide: () => { ipcRenderer.send('hud-overlay-hide'); @@ -63,4 +75,21 @@ contextBridge.exposeInMainWorld('electronAPI', { getPlatform: () => { return ipcRenderer.invoke('get-platform') }, + + // Mouse tracking APIs for auto-zoom feature + startMouseTracking: (sourceId: string, recordingStartTime: number) => { + return ipcRenderer.invoke('start-mouse-tracking', sourceId, recordingStartTime) + }, + stopMouseTracking: () => { + return ipcRenderer.invoke('stop-mouse-tracking') + }, + getCursorEvents: () => { + return ipcRenderer.invoke('get-cursor-events') + }, + storeCursorEvents: (events: CursorEvent[], videoFileName: string) => { + return ipcRenderer.invoke('store-cursor-events', events, videoFileName) + }, + loadCursorEvents: (videoPath: string) => { + return ipcRenderer.invoke('load-cursor-events', videoPath) + }, }) \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 1322701..52ba180 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "openscreen", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openscreen", - "version": "1.0.1", + "version": "1.0.2", + "hasInstallScript": true, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", "@pixi/filter-drop-shadow": "^5.2.0", @@ -41,6 +42,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.4", "uuid": "^13.0.0" }, "devDependencies": { @@ -8522,257 +8524,6 @@ "node": ">= 0.8.0" } }, - "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", - "dev": true, - "license": "MPL-2.0", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz", - "integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz", - "integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz", - "integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz", - "integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz", - "integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz", - "integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz", - "integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz", - "integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz", - "integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -9633,7 +9384,6 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -12593,6 +12343,19 @@ "node": ">=14.17" } }, + "node_modules/uiohook-napi": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/uiohook-napi/-/uiohook-napi-1.5.4.tgz", + "integrity": "sha512-7vPVDNwgb6MwTgviA/dnF2MrW0X5xm76fAqaOAC3cEKkswqAZOPw1USu14Sr6383s5qhXegcJaR63CpJOPCNAg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "4.x.x" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 7e092b1..3e9ae10 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,9 @@ "preview": "vite preview", "build:mac": "tsc && vite build && electron-builder --mac", "build:win": "tsc && vite build && electron-builder --win", - "build:linux": "tsc && vite build && electron-builder --linux" + "build:linux": "tsc && vite build && electron-builder --linux", + "rebuild": "electron-rebuild -f -w uiohook-napi", + "postinstall": "electron-rebuild -f -w uiohook-napi" }, "dependencies": { "@fix-webm-duration/fix": "^1.0.1", @@ -46,6 +48,7 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "tailwindcss-animate": "^1.0.7", + "uiohook-napi": "^1.5.4", "uuid": "^13.0.0" }, "devDependencies": { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index f82ae6c..0976735 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -8,10 +8,11 @@ import { MdMonitor } from "react-icons/md"; import { RxDragHandleDots2 } from "react-icons/rx"; import { FaFolderMinus } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; +import { TbZoomScan } from "react-icons/tb"; import { ContentClamp } from "../ui/content-clamp"; export function LaunchWindow() { - const { recording, toggleRecording } = useScreenRecorder(); + const { recording, toggleRecording, autoZoomEnabled, setAutoZoomEnabled } = useScreenRecorder(); const [recordingStart, setRecordingStart] = useState(null); const [elapsed, setElapsed] = useState(0); @@ -143,6 +144,23 @@ export function LaunchWindow() { +
+ + {/* Auto-zoom toggle button */} + +
diff --git a/src/components/video-editor/CursorOverlay.tsx b/src/components/video-editor/CursorOverlay.tsx new file mode 100644 index 0000000..9d24838 --- /dev/null +++ b/src/components/video-editor/CursorOverlay.tsx @@ -0,0 +1,341 @@ +import React, { useMemo, useEffect, useState } from 'react'; +import type { CursorSettings, CursorStyle } from './types'; +import type { CursorEvent } from '@/utils/cursorUtils'; + +/** Video bounds info for positioning cursor within the video area */ +export interface VideoBounds { + /** X offset of video from container left */ + x: number; + /** Y offset of video from container top */ + y: number; + /** Width of the visible video area */ + width: number; + /** Height of the visible video area */ + height: number; +} + +interface CursorOverlayProps { + cursorEvents: CursorEvent[]; + currentTimeMs: number; + cursorSettings: CursorSettings; + containerWidth: number; + containerHeight: number; + /** The bounds of the video within the container (accounting for padding) */ + videoBounds: VideoBounds; +} + +/** + * Find the cursor position at a given time by interpolating between cursor events + */ +function getCursorPositionAtTime( + cursorEvents: CursorEvent[], + timeMs: number +): { x: number; y: number; isClick: boolean } | null { + if (cursorEvents.length === 0) return null; + + let beforeEvent: CursorEvent | null = null; + let afterEvent: CursorEvent | null = null; + let clickEvent: CursorEvent | null = null; + + for (let i = 0; i < cursorEvents.length; i++) { + const event = cursorEvents[i]; + + if (event.type === 'click' && Math.abs(event.timestamp - timeMs) < 300) { + clickEvent = event; + } + + if (event.timestamp <= timeMs) { + beforeEvent = event; + } else if (!afterEvent) { + afterEvent = event; + break; + } + } + + if (!beforeEvent && afterEvent) { + return { x: afterEvent.normalizedX, y: afterEvent.normalizedY, isClick: !!clickEvent }; + } + + if (beforeEvent && !afterEvent) { + return { x: beforeEvent.normalizedX, y: beforeEvent.normalizedY, isClick: !!clickEvent }; + } + + if (beforeEvent && afterEvent) { + const timeDiff = afterEvent.timestamp - beforeEvent.timestamp; + if (timeDiff === 0) { + return { x: beforeEvent.normalizedX, y: beforeEvent.normalizedY, isClick: !!clickEvent }; + } + + const progress = (timeMs - beforeEvent.timestamp) / timeDiff; + const smoothProgress = progress * progress * (3 - 2 * progress); + + return { + x: beforeEvent.normalizedX + (afterEvent.normalizedX - beforeEvent.normalizedX) * smoothProgress, + y: beforeEvent.normalizedY + (afterEvent.normalizedY - beforeEvent.normalizedY) * smoothProgress, + isClick: !!clickEvent, + }; + } + + return null; +} + +/** Windows-style cursor SVG (black with white outline) */ +const WindowsCursor = ({ size }: { size: number }) => ( + + + +); + +/** Windows-style cursor SVG (white with black outline) */ +const WindowsCursorWhite = ({ size }: { size: number }) => ( + + + +); + +/** Mac-style cursor SVG (black with white outline) */ +const MacCursor = ({ size }: { size: number }) => ( + + + +); + +/** Mac-style cursor SVG (white with black outline) */ +const MacCursorWhite = ({ size }: { size: number }) => ( + + + +); + +/** + * Renders the cursor based on the style setting + */ +function renderCursor( + style: CursorStyle, + size: number, + color: string, + opacity: number, + isClick: boolean, + clickColor: string, + showClickAnimation: boolean +): React.ReactNode { + const effectiveColor = isClick && showClickAnimation ? clickColor : color; + const clickScale = isClick && showClickAnimation ? 0.9 : 1; + + const baseStyle: React.CSSProperties = { + opacity, + transform: `scale(${clickScale})`, + transition: 'transform 0.1s ease-out', + pointerEvents: 'none', + }; + + switch (style) { + case 'windows': + return ( +
+ +
+ ); + case 'windows-white': + return ( +
+ +
+ ); + case 'mac': + return ( +
+ +
+ ); + case 'mac-white': + return ( +
+ +
+ ); + case 'circle': + return ( +
+ ); + case 'ring': + return ( +
+ ); + case 'dot': + return ( +
+ ); + default: + return null; + } +} + +/** + * CursorOverlay renders a cursor indicator on top of the video + * based on recorded cursor events and current playback time. + * The cursor is positioned within the video bounds, accounting for padding. + */ +export function CursorOverlay({ + cursorEvents, + currentTimeMs, + cursorSettings, + videoBounds, +}: CursorOverlayProps) { + const [clickAnimation, setClickAnimation] = useState(false); + const [lastClickTime, setLastClickTime] = useState(-1000); + + // Get cursor position at current time (normalized 0-1) + const cursorPosition = useMemo(() => { + return getCursorPositionAtTime(cursorEvents, currentTimeMs); + }, [cursorEvents, currentTimeMs]); + + // Handle click animation + useEffect(() => { + if (cursorPosition?.isClick && cursorSettings.showClickAnimation) { + if (Math.abs(currentTimeMs - lastClickTime) > 100) { + setClickAnimation(true); + setLastClickTime(currentTimeMs); + const timeout = setTimeout(() => setClickAnimation(false), 200); + return () => clearTimeout(timeout); + } + } + }, [cursorPosition?.isClick, currentTimeMs, lastClickTime, cursorSettings.showClickAnimation]); + + // Don't render if cursor is not visible or no position + if (!cursorSettings.visible || !cursorPosition) { + return null; + } + + // Calculate pixel position within the video bounds + // The normalized position (0-1) maps to the video area, not the full container + const pixelX = videoBounds.x + cursorPosition.x * videoBounds.width; + const pixelY = videoBounds.y + cursorPosition.y * videoBounds.height; + + // Check if cursor is within the video bounds (with small tolerance for cursor size) + const tolerance = cursorSettings.size / 2; + const isWithinBounds = + pixelX >= videoBounds.x - tolerance && + pixelX <= videoBounds.x + videoBounds.width + tolerance && + pixelY >= videoBounds.y - tolerance && + pixelY <= videoBounds.y + videoBounds.height + tolerance; + + if (!isWithinBounds) { + return null; + } + + // For mouse cursor styles, position from top-left corner (hotspot) + // For abstract styles (circle, ring, dot), center on the point + const isMouseCursor = ['windows', 'windows-white', 'mac', 'mac-white'].includes(cursorSettings.style); + + return ( +
+ {renderCursor( + cursorSettings.style, + cursorSettings.size, + cursorSettings.color, + cursorSettings.opacity, + clickAnimation, + cursorSettings.clickColor, + cursorSettings.showClickAnimation + )} + + {/* Click ripple effect */} + {clickAnimation && cursorSettings.showClickAnimation && ( +
+ )} + + +
+ ); +} + +export default CursorOverlay; diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4a9d5f1..89de5f3 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -7,9 +7,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { useState } from "react"; import Block from '@uiw/react-color-block'; -import { Trash2, Download, Crop, X, Bug, Upload, Star } from "lucide-react"; +import { Trash2, Download, Crop, X, Bug, Upload, Star, MousePointer2 } from "lucide-react"; import { toast } from "sonner"; -import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types"; +import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, CursorSettings, CursorStyle } from "./types"; +import { CURSOR_STYLE_OPTIONS } 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; + cursorSettings?: CursorSettings; + onCursorSettingsChange?: (settings: CursorSettings) => void; + hasCursorEvents?: boolean; } export default SettingsPanel; @@ -124,10 +128,14 @@ export function SettingsPanel({ onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDelete, + cursorSettings, + onCursorSettingsChange, + hasCursorEvents = false, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); const fileInputRef = useRef(null); + const [showCursorSettings, setShowCursorSettings] = useState(false); useEffect(() => { let mounted = true @@ -146,6 +154,11 @@ export function SettingsPanel({ '#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107', '#34B27B', '#000000', '#607D8B', '#795548', ]; + + const cursorColorPalette = [ + '#FFCC00', '#FF6B6B', '#34B27B', '#3B82F6', '#8B5CF6', '#EC4899', + '#FFFFFF', '#000000', '#F97316', '#10B981', '#06B6D4', '#6366F1', + ]; const [selectedColor, setSelectedColor] = useState('#ADADAD'); const [gradient, setGradient] = useState(GRADIENTS[0]); @@ -383,6 +396,156 @@ export function SettingsPanel({ Crop Video
+ + {/* Cursor Settings Section */} + {hasCursorEvents && cursorSettings && onCursorSettingsChange && ( +
+ + + {showCursorSettings && ( +
+ {/* Show Cursor Toggle */} +
+
Show Cursor
+ onCursorSettingsChange({ ...cursorSettings, visible })} + className="data-[state=checked]:bg-[#34B27B]" + /> +
+ + {cursorSettings.visible && ( + <> + {/* Cursor Style */} +
+
Style
+
+ {CURSOR_STYLE_OPTIONS.map((option) => ( + + ))} +
+
+ + {/* Cursor Size */} +
+
+
Size
+ {cursorSettings.size}px +
+ onCursorSettingsChange({ ...cursorSettings, size: values[0] })} + min={8} + max={64} + step={1} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]" + /> +
+ + {/* Cursor Opacity */} +
+
+
Opacity
+ {Math.round(cursorSettings.opacity * 100)}% +
+ onCursorSettingsChange({ ...cursorSettings, opacity: values[0] })} + min={0.1} + max={1} + step={0.05} + className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B]" + /> +
+ + {/* Cursor Color - only for non-mouse cursor styles */} + {['circle', 'ring', 'dot'].includes(cursorSettings.style) && ( +
+
Color
+
+ {cursorColorPalette.map((color) => ( +
+
+ )} + + {/* Click Animation Toggle */} +
+
Click Animation
+ onCursorSettingsChange({ ...cursorSettings, showClickAnimation })} + className="data-[state=checked]:bg-[#34B27B]" + /> +
+ + {/* Click Color */} + {cursorSettings.showClickAnimation && ( +
+
Click Color
+
+ {cursorColorPalette.map((color) => ( +
+
+ )} + + {/* Preview */} +
+
Preview
+
+ +
+
+ + )} +
+ )} +
+ )} {showCropDropdown && cropRegion && onCropChange && ( <> @@ -623,3 +786,141 @@ export function SettingsPanel({
); } + +/** Windows-style cursor SVG (black with white outline) */ +const WindowsCursorPreview = ({ size }: { size: number }) => ( + + + +); + +/** Windows-style cursor SVG (white with black outline) */ +const WindowsCursorWhitePreview = ({ size }: { size: number }) => ( + + + +); + +/** Mac-style cursor SVG (black with white outline) */ +const MacCursorPreview = ({ size }: { size: number }) => ( + + + +); + +/** Mac-style cursor SVG (white with black outline) */ +const MacCursorWhitePreview = ({ size }: { size: number }) => ( + + + +); + +/** Preview component for cursor settings */ +function CursorPreview({ settings }: { settings: CursorSettings }) { + const { size, color, style, opacity } = settings; + + const renderCursor = () => { + const baseStyle: React.CSSProperties = { + opacity, + transition: 'all 0.2s ease-out', + }; + + switch (style) { + case 'windows': + return ( +
+ +
+ ); + case 'windows-white': + return ( +
+ +
+ ); + case 'mac': + return ( +
+ +
+ ); + case 'mac-white': + return ( +
+ +
+ ); + case 'circle': + return ( +
+ ); + case 'ring': + return ( +
+ ); + case 'dot': + return ( +
+ ); + default: + return null; + } + }; + + return ( +
+ {renderCursor()} +
+ ); +} \ No newline at end of file diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index eece277..f6ab475 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_SETTINGS, type ZoomDepth, type ZoomFocus, type ZoomRegion, @@ -27,10 +28,12 @@ import { type AnnotationRegion, type CropRegion, type FigureData, + type CursorSettings, } from "./types"; import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; import { getAssetPath } from "@/lib/assetPath"; +import { generateAutoZoomRegions, type CursorEvent } from "@/utils/cursorUtils"; const WALLPAPER_COUNT = 18; const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`); @@ -61,6 +64,9 @@ export default function VideoEditor() { const [showExportDialog, setShowExportDialog] = useState(false); const [aspectRatio, setAspectRatio] = useState('16:9'); const [exportQuality, setExportQuality] = useState('good'); + const [cursorEvents, setCursorEvents] = useState([]); + const [autoZoomApplied, setAutoZoomApplied] = useState(false); + const [cursorSettings, setCursorSettings] = useState(DEFAULT_CURSOR_SETTINGS); const videoPlaybackRef = useRef(null); const nextZoomIdRef = useRef(1); @@ -68,6 +74,7 @@ export default function VideoEditor() { const nextAnnotationIdRef = useRef(1); const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order const exporterRef = useRef(null); + const rawVideoPathRef = useRef(null); // Helper to convert file path to proper file:// URL const toFileUrl = (filePath: string): string => { @@ -93,6 +100,20 @@ export default function VideoEditor() { if (result.success && result.path) { const videoUrl = toFileUrl(result.path); setVideoPath(videoUrl); + rawVideoPathRef.current = result.path; + + // Load cursor events for auto-zoom + if (window.electronAPI?.loadCursorEvents) { + try { + const eventsResult = await window.electronAPI.loadCursorEvents(result.path); + if (eventsResult.success && eventsResult.events.length > 0) { + setCursorEvents(eventsResult.events); + console.log(`Loaded ${eventsResult.events.length} cursor events for auto-zoom`); + } + } catch (err) { + console.log('No cursor events found for this video'); + } + } } else { setError('No video to load. Please record or select a video.'); } @@ -105,6 +126,37 @@ export default function VideoEditor() { loadVideo(); }, []); + // Generate auto-zoom regions when cursor events are loaded and video duration is known + useEffect(() => { + if (cursorEvents.length > 0 && duration > 0 && !autoZoomApplied) { + const { regions, nextIdCounter } = generateAutoZoomRegions( + cursorEvents, + duration * 1000, // Convert to ms + nextZoomIdRef.current, + { + zoomDepth: 3, + zoomDurationMs: 2000, + minIntervalMs: 500, + keyframeSampleIntervalMs: 100, + keyframeMinDistance: 0.02, + } + ); + + if (regions.length > 0) { + setZoomRegions(regions); + nextZoomIdRef.current = nextIdCounter; + setAutoZoomApplied(true); + + const clickCount = cursorEvents.filter(e => e.type === 'click').length; + const keyframeCount = regions.reduce((sum, r) => sum + (r.focusKeyframes?.length || 0), 0); + toast.success(`Auto-generated ${regions.length} zoom regions with cursor following from ${clickCount} clicks`, { + duration: 4000, + }); + console.log(`Auto-zoom: ${regions.length} regions, ${keyframeCount} total keyframes`); + } + } + }, [cursorEvents, duration, autoZoomApplied]); + // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; @@ -316,7 +368,7 @@ export default function VideoEditor() { }); return updated; }); - }, []);; + }, []); const handleAnnotationTypeChange = useCallback((id: string, type: AnnotationRegion['type']) => { setAnnotationRegions((prev) => { @@ -690,6 +742,8 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorEvents={cursorEvents} + cursorSettings={cursorSettings} />
@@ -779,6 +833,9 @@ export default function VideoEditor() { onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} + cursorSettings={cursorSettings} + onCursorSettingsChange={setCursorSettings} + hasCursorEvents={cursorEvents.length > 0} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 357adc9..197ff0d 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -5,7 +5,7 @@ import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSou import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types"; import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants"; import { clamp01 } from "./videoPlayback/mathUtils"; -import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; +import { findDominantRegion, interpolateFocusFromKeyframes } from "./videoPlayback/zoomRegionUtils"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { updateOverlayIndicator } from "./videoPlayback/overlayUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; @@ -13,6 +13,9 @@ import { applyZoomTransform } from "./videoPlayback/zoomTransform"; import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers"; import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils"; import { AnnotationOverlay } from "./AnnotationOverlay"; +import { CursorOverlay } from "./CursorOverlay"; +import type { CursorSettings } from "./types"; +import type { CursorEvent } from "@/utils/cursorUtils"; interface VideoPlaybackProps { videoPath: string; @@ -41,6 +44,8 @@ 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; + cursorEvents?: CursorEvent[]; + cursorSettings?: CursorSettings; } export interface VideoPlaybackRef { @@ -80,6 +85,8 @@ const VideoPlayback = forwardRef(({ onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + cursorEvents = [], + cursorSettings, }, ref) => { const videoRef = useRef(null); const containerRef = useRef(null); @@ -90,6 +97,7 @@ const VideoPlayback = forwardRef(({ const timeUpdateAnimationRef = useRef(null); const [pixiReady, setPixiReady] = useState(false); const [videoReady, setVideoReady] = useState(false); + const [videoBounds, setVideoBounds] = useState({ x: 0, y: 0, width: 0, height: 0 }); const overlayRef = useRef(null); const focusIndicatorRef = useRef(null); const currentTimeRef = useRef(0); @@ -183,6 +191,9 @@ const VideoPlayback = forwardRef(({ baseOffsetRef.current = result.baseOffset; baseMaskRef.current = result.maskRect; cropBoundsRef.current = result.cropBounds; + + // Update video bounds state for cursor overlay + setVideoBounds(result.maskRect); // Reset camera container to identity cameraContainer.scale.set(1); @@ -626,7 +637,8 @@ const VideoPlayback = forwardRef(({ }; const ticker = () => { - const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current); + const currentTimeMs = currentTimeRef.current; + const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeMs); const defaultFocus = DEFAULT_FOCUS; let targetScaleFactor = 1; @@ -640,7 +652,13 @@ const VideoPlayback = forwardRef(({ if (region && strength > 0 && !shouldShowUnzoomedView) { const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = clampFocusToStage(region.focus, region.depth); + + // Get focus from keyframes if available, otherwise use static focus + const rawFocus = region.focusKeyframes && region.focusKeyframes.length > 1 + ? interpolateFocusFromKeyframes(region, currentTimeMs) + : region.focus; + + const regionFocus = clampFocusToStage(rawFocus, region.depth); // Interpolate scale and focus based on region strength targetScaleFactor = 1 + (zoomScale - 1) * strength; @@ -807,6 +825,17 @@ const VideoPlayback = forwardRef(({ : 'none', }} /> + {/* Cursor overlay - render on top of the video canvas */} + {pixiReady && videoReady && cursorSettings?.visible && cursorEvents.length > 0 && videoBounds.width > 0 && ( + + )} {/* Only render overlay after PIXI and video are fully initialized */} {pixiReady && videoReady && (
= [ + { style: 'windows', label: 'Windows' }, + { style: 'windows-white', label: 'Win White' }, + { style: 'mac', label: 'Mac' }, + { style: 'mac-white', label: 'Mac White' }, + { style: 'circle', label: 'Circle' }, + { style: 'ring', label: 'Ring' }, + { style: 'dot', label: 'Dot' }, +]; export interface CropRegion { x: number; diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts index e0dbf6b..d769ed1 100644 --- a/src/components/video-editor/videoPlayback/constants.ts +++ b/src/components/video-editor/videoPlayback/constants.ts @@ -1,7 +1,7 @@ import type { ZoomFocus } from "../types"; export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 }; -export const TRANSITION_WINDOW_MS = 320; -export const SMOOTHING_FACTOR = 0.12; -export const MIN_DELTA = 0.0001; +export const TRANSITION_WINDOW_MS = 400; // Longer transition for smoother zoom in/out +export const SMOOTHING_FACTOR = 0.08; // Lower = smoother but more lag +export const MIN_DELTA = 0.00005; // Finer precision for smoother stopping export const VIEWPORT_SCALE = 0.8; diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts index c629d59..6e3c086 100644 --- a/src/components/video-editor/videoPlayback/mathUtils.ts +++ b/src/components/video-editor/videoPlayback/mathUtils.ts @@ -2,7 +2,35 @@ export function clamp01(value: number) { return Math.max(0, Math.min(1, value)); } +/** Standard smoothstep - good for general transitions */ export function smoothStep(t: number) { const clamped = clamp01(t); return clamped * clamped * (3 - 2 * clamped); } + +/** Smoother step (Ken Perlin's improved version) - better for cursor following */ +export function smootherStep(t: number) { + const clamped = clamp01(t); + return clamped * clamped * clamped * (clamped * (clamped * 6 - 15) + 10); +} + +/** Attempt Catmull-Rom spline interpolation for 4 points */ +export function catmullRomInterpolate( + p0: number, + p1: number, + p2: number, + p3: number, + t: number, + tension: number = 0.5 +): number { + const t2 = t * t; + const t3 = t2 * t; + + const m1 = tension * (p2 - p0); + const m2 = tension * (p3 - p1); + + return (2 * t3 - 3 * t2 + 1) * p1 + + (t3 - 2 * t2 + t) * m1 + + (-2 * t3 + 3 * t2) * p2 + + (t3 - t2) * m2; +} diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts index 26a3817..87486cd 100644 --- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts +++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts @@ -1,5 +1,5 @@ -import type { ZoomRegion } from "../types"; -import { smoothStep } from "./mathUtils"; +import type { ZoomRegion, ZoomFocus } from "../types"; +import { smootherStep, catmullRomInterpolate } from "./mathUtils"; import { TRANSITION_WINDOW_MS } from "./constants"; export function computeRegionStrength(region: ZoomRegion, timeMs: number) { @@ -10,11 +10,73 @@ export function computeRegionStrength(region: ZoomRegion, timeMs: number) { return 0; } - const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS); - const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS); + // Use smootherStep for more pleasant zoom transitions + const fadeIn = smootherStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS); + const fadeOut = smootherStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS); return Math.min(fadeIn, fadeOut); } +/** + * Interpolate focus position from keyframes using Catmull-Rom splines + * for ultra-smooth cursor following that preserves natural motion. + */ +export function interpolateFocusFromKeyframes( + region: ZoomRegion, + currentTimeMs: number +): ZoomFocus { + const keyframes = region.focusKeyframes; + + // If no keyframes, return the static focus + if (!keyframes || keyframes.length === 0) { + return region.focus; + } + + // Calculate time offset within the zoom region + const timeOffset = currentTimeMs - region.startMs; + + // Before first keyframe - use first keyframe focus + if (timeOffset <= keyframes[0].timeOffsetMs) { + return keyframes[0].focus; + } + + // After last keyframe - use last keyframe focus + const lastKeyframe = keyframes[keyframes.length - 1]; + if (timeOffset >= lastKeyframe.timeOffsetMs) { + return lastKeyframe.focus; + } + + // Find the keyframe index we're at + let currentIndex = 0; + for (let i = 0; i < keyframes.length - 1; i++) { + if (timeOffset >= keyframes[i].timeOffsetMs && timeOffset < keyframes[i + 1].timeOffsetMs) { + currentIndex = i; + break; + } + } + + // Get 4 points for Catmull-Rom: p0, p1 (current), p2 (next), p3 + const p1 = keyframes[currentIndex]; + const p2 = keyframes[currentIndex + 1]; + + // For edge cases, duplicate endpoints + const p0 = currentIndex > 0 ? keyframes[currentIndex - 1] : p1; + const p3 = currentIndex + 2 < keyframes.length ? keyframes[currentIndex + 2] : p2; + + // Calculate interpolation factor (0-1) between p1 and p2 + const segmentDuration = p2.timeOffsetMs - p1.timeOffsetMs; + if (segmentDuration <= 0) { + return p1.focus; + } + + const t = (timeOffset - p1.timeOffsetMs) / segmentDuration; + + // Use Catmull-Rom spline for smooth curves through all keyframes + return { + cx: catmullRomInterpolate(p0.focus.cx, p1.focus.cx, p2.focus.cx, p3.focus.cx, t, 0.5), + cy: catmullRomInterpolate(p0.focus.cy, p1.focus.cy, p2.focus.cy, p3.focus.cy, t, 0.5), + }; +} + export function findDominantRegion(regions: ZoomRegion[], timeMs: number) { let bestRegion: ZoomRegion | null = null; let bestStrength = 0; diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts index cfb2183..405aa88 100644 --- a/src/hooks/useScreenRecorder.ts +++ b/src/hooks/useScreenRecorder.ts @@ -4,14 +4,18 @@ import { fixWebmDuration } from "@fix-webm-duration/fix"; type UseScreenRecorderReturn = { recording: boolean; toggleRecording: () => void; + autoZoomEnabled: boolean; + setAutoZoomEnabled: (enabled: boolean) => void; }; export function useScreenRecorder(): UseScreenRecorderReturn { const [recording, setRecording] = useState(false); + const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); const mediaRecorder = useRef(null); const stream = useRef(null); const chunks = useRef([]); const startTime = useRef(0); + const sourceIdRef = useRef(""); // Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up const TARGET_FRAME_RATE = 60; @@ -45,7 +49,7 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return Math.round(18_000_000 * highFrameRateBoost); }; - const stopRecording = useRef(() => { + const stopRecording = useRef(async () => { if (mediaRecorder.current?.state === "recording") { if (stream.current) { stream.current.getTracks().forEach(track => track.stop()); @@ -53,6 +57,11 @@ export function useScreenRecorder(): UseScreenRecorderReturn { mediaRecorder.current.stop(); setRecording(false); + // Stop mouse tracking when recording stops + if (window.electronAPI?.stopMouseTracking) { + await window.electronAPI.stopMouseTracking(); + } + window.electronAPI?.setRecordingState(false); } }); @@ -87,6 +96,9 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + // Store source ID for mouse tracking + sourceIdRef.current = selectedSource.id; + const mediaStream = await (navigator.mediaDevices as any).getUserMedia({ audio: false, video: { @@ -159,6 +171,15 @@ export function useScreenRecorder(): UseScreenRecorderReturn { return; } + // Store cursor events if mouse tracking was enabled + if (window.electronAPI?.getCursorEvents) { + const eventsResult = await window.electronAPI.getCursorEvents(); + if (eventsResult.success && eventsResult.events.length > 0) { + await window.electronAPI.storeCursorEvents(eventsResult.events, videoFileName); + console.log(`Stored ${eventsResult.events.length} cursor events for ${videoFileName}`); + } + } + if (videoResult.path) { await window.electronAPI.setCurrentVideoPath(videoResult.path); } @@ -173,6 +194,12 @@ export function useScreenRecorder(): UseScreenRecorderReturn { startTime.current = Date.now(); setRecording(true); window.electronAPI?.setRecordingState(true); + + // Start mouse tracking if auto-zoom is enabled + if (autoZoomEnabled && window.electronAPI?.startMouseTracking) { + await window.electronAPI.startMouseTracking(selectedSource.id, startTime.current); + console.log('Mouse tracking started for auto-zoom'); + } } catch (error) { console.error('Failed to start recording:', error); setRecording(false); @@ -187,5 +214,5 @@ export function useScreenRecorder(): UseScreenRecorderReturn { recording ? stopRecording.current() : startRecording(); }; - return { recording, toggleRecording }; + return { recording, toggleRecording, autoZoomEnabled, setAutoZoomEnabled }; } diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts index d0ba1d9..1090b1f 100644 --- a/src/lib/exporter/frameRenderer.ts +++ b/src/lib/exporter/frameRenderer.ts @@ -1,7 +1,7 @@ import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js'; import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types'; import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types'; -import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; +import { findDominantRegion, interpolateFocusFromKeyframes } from '@/components/video-editor/videoPlayback/zoomRegionUtils'; import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform'; import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants'; import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils'; @@ -401,7 +401,13 @@ export class FrameRenderer { if (region && strength > 0) { const zoomScale = ZOOM_DEPTH_SCALES[region.depth]; - const regionFocus = this.clampFocusToStage(region.focus, region.depth); + + // Get focus from keyframes if available, otherwise use static focus + const rawFocus = region.focusKeyframes && region.focusKeyframes.length > 1 + ? interpolateFocusFromKeyframes(region, timeMs) + : region.focus; + + const regionFocus = this.clampFocusToStage(rawFocus, region.depth); targetScaleFactor = 1 + (zoomScale - 1) * strength; targetFocus = { diff --git a/src/utils/cursorUtils.ts b/src/utils/cursorUtils.ts new file mode 100644 index 0000000..e343e7a --- /dev/null +++ b/src/utils/cursorUtils.ts @@ -0,0 +1,260 @@ +import type { ZoomRegion, ZoomDepth, ZoomFocus, ZoomFocusKeyframe } from '../components/video-editor/types'; + +/** + * Cursor event captured during recording + */ +export interface CursorEvent { + type: 'move' | 'click' | 'scroll'; + timestamp: number; // ms since recording start + x: number; + y: number; + normalizedX: number; // 0-1 + normalizedY: number; // 0-1 + button?: number; + scrollDelta?: number; +} + +/** + * Configuration for auto-zoom generation + */ +export interface AutoZoomConfig { + /** Zoom depth to use for auto-generated zooms (1-6) */ + zoomDepth: ZoomDepth; + /** Duration of the zoom effect in ms */ + zoomDurationMs: number; + /** Minimum time between auto-zooms in ms */ + minIntervalMs: number; + /** Interval for sampling cursor position for keyframes in ms */ + keyframeSampleIntervalMs: number; + /** Minimum distance (normalized) to create a new keyframe */ + keyframeMinDistance: number; +} + +const DEFAULT_AUTO_ZOOM_CONFIG: AutoZoomConfig = { + zoomDepth: 3, + zoomDurationMs: 2000, + minIntervalMs: 500, + keyframeSampleIntervalMs: 50, // Sample every 50ms for smoother curves + keyframeMinDistance: 0.01, // 1% of screen - capture finer movements +}; + +/** + * Generate focus keyframes from cursor movements during a zoom period. + * Creates smooth cursor-following animation within the zoom region. + */ +function generateFocusKeyframes( + cursorEvents: CursorEvent[], + zoomStartMs: number, + zoomEndMs: number, + initialFocus: ZoomFocus, + config: AutoZoomConfig +): ZoomFocusKeyframe[] { + const keyframes: ZoomFocusKeyframe[] = []; + + // First keyframe at the click position + keyframes.push({ + timeOffsetMs: 0, + focus: { ...initialFocus }, + }); + + // Get all move events during the zoom period + const movesDuringZoom = cursorEvents.filter( + (e) => e.type === 'move' && e.timestamp >= zoomStartMs && e.timestamp <= zoomEndMs + ); + + if (movesDuringZoom.length === 0) { + return keyframes; + } + + // Sample cursor positions at regular intervals + let lastKeyframeFocus = initialFocus; + let lastSampleTime = zoomStartMs; + + for (const moveEvent of movesDuringZoom) { + // Check if enough time has passed since last sample + if (moveEvent.timestamp - lastSampleTime < config.keyframeSampleIntervalMs) { + continue; + } + + const newFocus: ZoomFocus = { + cx: Math.max(0, Math.min(1, moveEvent.normalizedX)), + cy: Math.max(0, Math.min(1, moveEvent.normalizedY)), + }; + + // Check if cursor moved enough to warrant a new keyframe + const distance = Math.sqrt( + Math.pow(newFocus.cx - lastKeyframeFocus.cx, 2) + + Math.pow(newFocus.cy - lastKeyframeFocus.cy, 2) + ); + + if (distance >= config.keyframeMinDistance) { + keyframes.push({ + timeOffsetMs: moveEvent.timestamp - zoomStartMs, + focus: newFocus, + }); + lastKeyframeFocus = newFocus; + lastSampleTime = moveEvent.timestamp; + } + } + + // Add final position if we have movement and last keyframe is not at the end + if (movesDuringZoom.length > 0) { + const lastMove = movesDuringZoom[movesDuringZoom.length - 1]; + const lastKeyframe = keyframes[keyframes.length - 1]; + + // Only add if significantly different from last keyframe + if (lastKeyframe && lastMove.timestamp - zoomStartMs - lastKeyframe.timeOffsetMs > 50) { + const finalFocus: ZoomFocus = { + cx: Math.max(0, Math.min(1, lastMove.normalizedX)), + cy: Math.max(0, Math.min(1, lastMove.normalizedY)), + }; + + const distance = Math.sqrt( + Math.pow(finalFocus.cx - lastKeyframe.focus.cx, 2) + + Math.pow(finalFocus.cy - lastKeyframe.focus.cy, 2) + ); + + if (distance >= config.keyframeMinDistance * 0.5) { + keyframes.push({ + timeOffsetMs: lastMove.timestamp - zoomStartMs, + focus: finalFocus, + }); + } + } + } + + return keyframes; +} + +/** + * Generate zoom regions from cursor click events. + * Creates smooth auto-zooms that follow cursor movement throughout the zoom duration. + */ +export function generateAutoZoomRegions( + cursorEvents: CursorEvent[], + videoDurationMs: number, + existingZoomIdCounter: number = 0, + config: Partial = {} +): { regions: ZoomRegion[]; nextIdCounter: number } { + const cfg: AutoZoomConfig = { ...DEFAULT_AUTO_ZOOM_CONFIG, ...config }; + + // Filter to only click events (left-click primarily) + const clickEvents = cursorEvents.filter( + (e) => e.type === 'click' && (e.button === 1 || e.button === undefined) + ); + + if (clickEvents.length === 0) { + return { regions: [], nextIdCounter: existingZoomIdCounter }; + } + + const regions: ZoomRegion[] = []; + let idCounter = existingZoomIdCounter; + let lastZoomEndMs = -cfg.minIntervalMs; + + for (const clickEvent of clickEvents) { + // Skip if too close to last zoom + if (clickEvent.timestamp - lastZoomEndMs < cfg.minIntervalMs) { + continue; + } + + const startMs = clickEvent.timestamp; + const endMs = Math.min(startMs + cfg.zoomDurationMs, videoDurationMs); + + // Skip if zoom would extend beyond video or too short + if (endMs - startMs < 300) { + continue; + } + + // Initial focus on click position + const initialFocus: ZoomFocus = { + cx: Math.max(0, Math.min(1, clickEvent.normalizedX)), + cy: Math.max(0, Math.min(1, clickEvent.normalizedY)), + }; + + // Generate keyframes from cursor movement during the zoom + const focusKeyframes = generateFocusKeyframes( + cursorEvents, + startMs, + endMs, + initialFocus, + cfg + ); + + const region: ZoomRegion = { + id: `auto-zoom-${++idCounter}`, + startMs: Math.round(startMs), + endMs: Math.round(endMs), + depth: cfg.zoomDepth, + focus: initialFocus, + focusKeyframes: focusKeyframes.length > 1 ? focusKeyframes : undefined, + }; + + regions.push(region); + lastZoomEndMs = endMs; + } + + return { regions, nextIdCounter: idCounter }; +} + +/** + * Merge auto-generated zoom regions with existing manual regions. + * Manual regions take precedence - auto-zooms are removed if they overlap. + */ +export function mergeZoomRegions( + autoRegions: ZoomRegion[], + manualRegions: ZoomRegion[] +): ZoomRegion[] { + // Start with all manual regions + const merged: ZoomRegion[] = [...manualRegions]; + + // Add auto regions that don't overlap with manual ones + for (const autoRegion of autoRegions) { + const overlapsManual = manualRegions.some( + (manual) => + !(autoRegion.endMs <= manual.startMs || autoRegion.startMs >= manual.endMs) + ); + + if (!overlapsManual) { + merged.push(autoRegion); + } + } + + // Sort by start time + return merged.sort((a, b) => a.startMs - b.startMs); +} + +/** + * Analyze cursor events to provide statistics about the recording + */ +export function analyzeCursorEvents(events: CursorEvent[]): { + totalClicks: number; + totalMoves: number; + totalScrolls: number; + avgClickInterval: number; + recordingDuration: number; +} { + const clicks = events.filter((e) => e.type === 'click'); + const moves = events.filter((e) => e.type === 'move'); + const scrolls = events.filter((e) => e.type === 'scroll'); + + let avgClickInterval = 0; + if (clicks.length > 1) { + const intervals: number[] = []; + for (let i = 1; i < clicks.length; i++) { + intervals.push(clicks[i].timestamp - clicks[i - 1].timestamp); + } + avgClickInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length; + } + + const recordingDuration = events.length > 0 + ? Math.max(...events.map((e) => e.timestamp)) + : 0; + + return { + totalClicks: clicks.length, + totalMoves: moves.length, + totalScrolls: scrolls.length, + avgClickInterval, + recordingDuration, + }; +} diff --git a/vite.config.ts b/vite.config.ts index e6b6840..53da8f7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,10 @@ export default defineConfig({ entry: 'electron/main.ts', vite: { build: { - + rollupOptions: { + // Mark native modules as external to prevent bundling + external: ['uiohook-napi'], + }, } } },