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(); } } 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"); } }); 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 }> 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) + }; + } + }); } 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') + }, +}) 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 diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index 4a9d5f1..d9dcb8e 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, CursorSmoothing, End2EndParams, ZoomFollowMode } from "./types"; import { CropControl } from "./CropControl"; import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp"; import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel"; @@ -78,6 +79,24 @@ 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; + cursorSmoothing?: CursorSmoothing; + onCursorSmoothingChange?: (s: CursorSmoothing) => void; + quadraticSmoothingStrength?: number; + onQuadraticSmoothingStrengthChange?: (v: number) => void; + end2endParams?: End2EndParams; + onEnd2endParamsChange?: (p: Partial) => void; + // Zoom follow settings + zoomFollowEnabled?: boolean; + onZoomFollowEnabledChange?: (enabled: boolean) => void; + zoomFollowMode?: ZoomFollowMode; + onZoomFollowModeChange?: (mode: ZoomFollowMode) => void; + zoomFollowDelayMs?: number; + onZoomFollowDelayMsChange?: (ms: number) => void; + zoomFollowMinPaddingPx?: number; + onZoomFollowMinPaddingPxChange?: (px: number) => void; } export default SettingsPanel; @@ -124,6 +143,24 @@ export function SettingsPanel({ onAnnotationStyleChange, onAnnotationFigureDataChange, onAnnotationDelete, + cursorTrack, + selectedCursorId, + onCursorStyleChange, + cursorSmoothing, + onCursorSmoothingChange, + quadraticSmoothingStrength, + onQuadraticSmoothingStrengthChange, + end2endParams, + onEnd2endParamsChange, + // Zoom follow defaults + zoomFollowEnabled = false, + onZoomFollowEnabledChange, + zoomFollowMode = 'center', + onZoomFollowModeChange, + zoomFollowDelayMs = 120, + onZoomFollowDelayMsChange, + zoomFollowMinPaddingPx = 24, + onZoomFollowMinPaddingPxChange, }: SettingsPanelProps) { const [wallpaperPaths, setWallpaperPaths] = useState([]); const [customImages, setCustomImages] = useState([]); @@ -150,9 +187,21 @@ export function SettingsPanel({ const [selectedColor, setSelectedColor] = useState('#ADADAD'); const [gradient, setGradient] = useState(GRADIENTS[0]); const [showCropDropdown, setShowCropDropdown] = useState(false); + // Local follow state to allow toggling even if parent doesn't pass handler + const [zoomFollowEnabledLocal, setZoomFollowEnabledLocal] = useState(Boolean((zoomFollowEnabled as boolean) || false)); + useEffect(() => { + setZoomFollowEnabledLocal(Boolean(zoomFollowEnabled)); + }, [zoomFollowEnabled]); + // Mirror to global fallback so VideoPlayback can read when parent doesn't wire props + useEffect(() => { + try { + (window as any).__openscreen_zoomFollowEnabled = Boolean(zoomFollowEnabledLocal); + } catch {} + }, [zoomFollowEnabledLocal]); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); + const cursorEnabled = Boolean(selectedCursorId && cursorTrack && cursorTrack.events.length > 0); const handleDeleteClick = () => { if (selectedZoomId && onZoomDelete) { @@ -234,6 +283,239 @@ 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
+ +
+
+
+
Cursor Offset
+ {cursorTrack?.style?.offsetMs ?? 0}ms ยท {(cursorTrack?.style?.offsetX ?? 0)}px,{(cursorTrack?.style?.offsetY ?? 0)}px +
+
+
+
Time (ms)
+
+ onCursorStyleChange?.({ offsetMs: Number(e.target.value) })} + className="w-full p-2 rounded bg-black/20 text-slate-200" + /> + + +
+
+
+
+
X (px)
+
+ onCursorStyleChange?.({ offsetX: Number(e.target.value) })} + className="w-full p-2 rounded bg-black/20 text-slate-200" + /> + + +
+
+
+
Y (px)
+
+ onCursorStyleChange?.({ offsetY: Number(e.target.value) })} + className="w-full p-2 rounded bg-black/20 text-slate-200" + /> + + +
+
+
+
+
+
+
Path Smoothing
+ +
+ {cursorSmoothing === 'quadratic' && ( +
+
Quadratic smoothing strength
+
Adjust how strongly quadratic smoothing curves the path
+ onQuadraticSmoothingStrengthChange?.(Math.max(0, Math.min(1, vals[0] / 100)))} + min={0} + max={100} + step={1} + className="w-full [&_[role=slider]]:bg-[#4C8BF5] [&_[role=slider]]:border-[#4C8BF5]" + /> +
{Math.round((quadraticSmoothingStrength ?? 0.5) * 100)}%
+
+ )} + {cursorSmoothing === 'end2end' && end2endParams && ( +
+
Dwell detection time
+
How long the mouse must remain approximately still to count as a drop point
+ onEnd2endParamsChange?.({ dwellTimeMs: vals[0] })} + min={200} + max={600} + step={10} + className="w-full [&_[role=slider]]:bg-[#4C8BF5] [&_[role=slider]]:border-[#4C8BF5]" + /> + +
Dwell sensitivity
+
Allowed small movement while considered 'still'
+ + +
Min move distance for new drop
+
Prevent generating multiple drop points at the same location
+ onEnd2endParamsChange?.({ minJumpDistancePx: vals[0] })} + min={10} + max={40} + step={1} + className="w-full [&_[role=slider]]:bg-[#4C8BF5] [&_[role=slider]]:border-[#4C8BF5]" + /> + +
Minimum interval between drop points
+
Minimum time between two generated drop points (ms)
+ onEnd2endParamsChange?.({ minTimeBetweenEndpointsMs: Number(e.target.value) })} + min={100} + max={500} + step={10} + className="w-full p-2 rounded bg-black/20 text-slate-200" + /> + +
Arrival fraction
+
Fraction of the segment duration used to move between drop points; smaller values make the cursor arrive and pause
+
+ onEnd2endParamsChange?.({ arrivalFraction: Math.max(0.2, Math.min(1, vals[0] / 100)) })} + min={20} + max={100} + step={5} + className="w-full [&_[role=slider]]:bg-[#4C8BF5] [&_[role=slider]]:border-[#4C8BF5]" + /> +
{Math.round((end2endParams.arrivalFraction ?? 1) * 100)}%
+
+
+ )} +
+
+ )}
Zoom Level @@ -243,6 +525,22 @@ export function SettingsPanel({ {ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label} Active )} + {/* Compact Zoom Follow toggle placed in header for visibility (only when a zoom region is selected) */} + {selectedZoomId && ( +
+
Follow
+ { + const next = typeof v === 'boolean' ? v : !zoomFollowEnabledLocal; + setZoomFollowEnabledLocal(next); + onZoomFollowEnabledChange?.(next); + try { (window as any).__openscreen_zoomFollowEnabled = Boolean(next); } catch {} + }} + className="data-[state=checked]:bg-[#34B27B] h-6 w-10" + /> +
+ )}
@@ -283,6 +581,75 @@ export function SettingsPanel({ Delete Zoom Region )} + {/* Zoom Follow Controls: only visible when a zoom region is selected */} + {selectedZoomId && ( +
+
+
Zoom Follow
+ {/* Mirror local state in main control */} + { + const next = typeof v === 'boolean' ? v : !zoomFollowEnabledLocal; + setZoomFollowEnabledLocal(next); + onZoomFollowEnabledChange?.(next); + }} + className="data-[state=checked]:bg-[#34B27B]" + /> +
+ {zoomFollowEnabledLocal && ( +
+
+
Mode
+ +
+ + {zoomFollowMode === 'center' && ( +
+
Delay (ms)
+
+ onZoomFollowDelayMsChange?.(Number(e.target.value))} + className="w-full p-2 rounded bg-black/20 text-slate-200" + min={0} + /> +
ms
+
+
+ )} + + {zoomFollowMode === 'anchor' && ( +
+
Min padding (px)
+
+ onZoomFollowMinPaddingPxChange?.(Number(e.target.value))} + className="w-full p-2 rounded bg-black/20 text-slate-200" + min={0} + /> +
px
+
+
+ )} +
+ )} +
+ )}
{/* Trim Delete Section */} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index eece277..6efb7ca 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,10 @@ import { type AnnotationRegion, type CropRegion, type FigureData, + type CursorTrack, + type CursorStyle, + type CursorSmoothing, + type End2EndParams, } from "./types"; import { VideoExporter, type ExportProgress, type ExportQuality } from "@/lib/exporter"; import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils"; @@ -37,6 +42,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 +61,18 @@ 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 [cursorEnabled, setCursorEnabled] = useState(true); + const [cursorSmoothing, setCursorSmoothing] = useState('none'); + const [quadraticSmoothingStrength, setQuadraticSmoothingStrength] = useState(0.5); + const [end2endParams, setEnd2endParams] = useState({ + dwellTimeMs: 300, + stillEpsilonPx: 3, + minJumpDistancePx: 18, + minTimeBetweenEndpointsMs: 200, + arrivalFraction: 0.6, + }); const [isExporting, setIsExporting] = useState(false); const [exportProgress, setExportProgress] = useState(null); const [exportError, setExportError] = useState(null); @@ -93,6 +111,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 +124,54 @@ 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, + offsetMs: typeof parsed?.style?.offsetMs === 'number' ? parsed.style.offsetMs : undefined, + offsetX: typeof parsed?.style?.offsetX === 'number' ? parsed.style.offsetX : undefined, + offsetY: typeof parsed?.style?.offsetY === 'number' ? parsed.style.offsetY : undefined, + }, + }); + } catch (err) { + if (mounted) { + setCursorTrack(null); + } + } + })(); + + return () => { + mounted = false; + }; + }, [videoFilePath]); + // Initialize default wallpaper with resolved asset path useEffect(() => { let mounted = true; @@ -143,6 +210,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 +218,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedAnnotationId(null); + setSelectedCursorId(null); } }, []); @@ -158,6 +227,7 @@ export default function VideoEditor() { if (id) { setSelectedZoomId(null); setSelectedTrimId(null); + setSelectedCursorId(null); } }, []); @@ -382,6 +452,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 +520,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 +782,11 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTrack={cursorTrack} + cursorEnabled={cursorEnabled} + cursorSmoothing={cursorSmoothing} + quadraticSmoothingStrength={quadraticSmoothingStrength} + end2endParams={end2endParams} /> @@ -737,6 +834,13 @@ export default function VideoEditor() { onAnnotationDelete={handleAnnotationDelete} selectedAnnotationId={selectedAnnotationId} onSelectAnnotation={handleSelectAnnotation} + cursorTrack={cursorTrack} + selectedCursorId={selectedCursorId} + onSelectCursor={handleSelectCursor} + cursorEnabled={cursorEnabled} + onCursorEnabledChange={setCursorEnabled} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={setCursorSmoothing} aspectRatio={aspectRatio} onAspectRatioChange={setAspectRatio} /> @@ -779,6 +883,15 @@ export default function VideoEditor() { onAnnotationStyleChange={handleAnnotationStyleChange} onAnnotationFigureDataChange={handleAnnotationFigureDataChange} onAnnotationDelete={handleAnnotationDelete} + cursorTrack={cursorTrack} + selectedCursorId={selectedCursorId} + onCursorStyleChange={handleCursorStyleChange} + cursorSmoothing={cursorSmoothing} + onCursorSmoothingChange={setCursorSmoothing} + quadraticSmoothingStrength={quadraticSmoothingStrength} + onQuadraticSmoothingStrengthChange={setQuadraticSmoothingStrength} + end2endParams={end2endParams} + onEnd2endParamsChange={(p) => setEnd2endParams(prev => ({ ...prev, ...p }))} /> @@ -794,4 +907,4 @@ export default function VideoEditor() { /> ); -} \ No newline at end of file +} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 357adc9..096385a 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -1,8 +1,28 @@ import type React from "react"; -import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } 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 { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from "pixi.js"; +import { + ZOOM_DEPTH_SCALES, + type ZoomRegion, + type ZoomFocus, + type ZoomDepth, + type TrimRegion, + type AnnotationRegion, + type CursorTrack, + DEFAULT_CURSOR_STYLE, + type CursorSmoothing, + type End2EndParams, +} from "./types"; +import { extractPausePointsFromDisplayEvents, evaluatePositionOnCRByTime, sampleCRPath } from "./end2endSmoother"; import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants"; import { clamp01 } from "./videoPlayback/mathUtils"; import { findDominantRegion } from "./videoPlayback/zoomRegionUtils"; @@ -41,6 +61,16 @@ 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; + cursorEnabled?: boolean; + cursorSmoothing?: CursorSmoothing; + quadraticSmoothingStrength?: number; + end2endParams?: End2EndParams; + // Zoom follow options (optional) + zoomFollowEnabled?: boolean; + zoomFollowMode?: 'center' | 'anchor'; + zoomFollowDelayMs?: number; + zoomFollowMinPaddingPx?: number; } export interface VideoPlaybackRef { @@ -53,34 +83,47 @@ export interface VideoPlaybackRef { pause: () => void; } -const VideoPlayback = forwardRef(({ - videoPath, - onDurationChange, - onTimeUpdate, - currentTime, - onPlayStateChange, - onError, - wallpaper, - zoomRegions, - selectedZoomId, - onSelectZoom, - onZoomFocusChange, - isPlaying, - showShadow, - shadowIntensity = 0, - showBlur, - motionBlurEnabled = true, - borderRadius = 0, - padding = 50, - cropRegion, - trimRegions = [], - aspectRatio, - annotationRegions = [], - selectedAnnotationId, - onSelectAnnotation, - onAnnotationPositionChange, - onAnnotationSizeChange, -}, ref) => { +function VideoPlayback( + { + videoPath, + onDurationChange, + onTimeUpdate, + currentTime, + onPlayStateChange, + onError, + wallpaper, + zoomRegions, + selectedZoomId, + onSelectZoom, + onZoomFocusChange, + isPlaying, + showShadow, + shadowIntensity = 0, + showBlur, + motionBlurEnabled = true, + borderRadius = 0, + padding = 50, + cropRegion, + trimRegions = [], + aspectRatio, + annotationRegions = [], + selectedAnnotationId, + onSelectAnnotation, + onAnnotationPositionChange, + onAnnotationSizeChange, + cursorTrack, + cursorEnabled = true, + cursorSmoothing = 'none', + quadraticSmoothingStrength, + end2endParams, + // Zoom follow props + zoomFollowEnabled = false, + zoomFollowMode = 'center', + zoomFollowDelayMs = 120, + zoomFollowMinPaddingPx = 24, + }: VideoPlaybackProps, + ref: React.Ref +) { const videoRef = useRef(null); const containerRef = useRef(null); const appRef = useRef(null); @@ -92,6 +135,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,10 +159,188 @@ 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); }, []); + // Helper: compute stage pixel coords (pre-camera transform) from normalized video coords (nx, ny) + const getStageCoordsFromNormalized = useCallback((nx: number, ny: number) => { + const lockedDims = lockedVideoDimensionsRef.current; + if (!lockedDims || lockedDims.width === 0 || lockedDims.height === 0) return null; + + const fullVideoWidth = lockedDims.width; + const fullVideoHeight = lockedDims.height; + + const videoX = nx * fullVideoWidth; + const videoY = ny * fullVideoHeight; + + const cropBounds = cropBoundsRef.current; + if (cropBounds.endX > cropBounds.startX && cropBounds.endY > cropBounds.startY) { + if (videoX < cropBounds.startX || videoX > cropBounds.endX || + videoY < cropBounds.startY || videoY > cropBounds.endY) { + return null; + } + } + + const baseScale = baseScaleRef.current; + const baseOffset = baseOffsetRef.current; + if (!stageSizeRef.current.width || !stageSizeRef.current.height || baseScale <= 0) { + return null; + } + + const stageX = baseOffset.x + videoX * baseScale; + const stageY = baseOffset.y + videoY * baseScale; + return { stageX, stageY }; + }, []); + const updateOverlayForRegion = useCallback((region: ZoomRegion | null, focusOverride?: ZoomFocus) => { const overlayEl = overlayRef.current; const indicatorEl = focusIndicatorRef.current; @@ -290,9 +513,7 @@ const VideoPlayback = forwardRef(({ isDraggingFocusRef.current = false; try { event.currentTarget.releasePointerCapture(event.pointerId); - } catch { - - } + } catch { /* empty */ } }; const handleOverlayPointerUp = (event: React.PointerEvent) => { @@ -311,6 +532,159 @@ const VideoPlayback = forwardRef(({ selectedZoomIdRef.current = selectedZoomId; }, [selectedZoomId]); + // Follow anchor ref and keep props in refs for synchronous access in ticker + const followAnchorRef = useRef(null); + const zoomFollowEnabledRef = useRef(zoomFollowEnabled); + const zoomFollowModeRef = useRef<'center' | 'anchor'>(zoomFollowMode); + const zoomFollowDelayMsRef = useRef(zoomFollowDelayMs); + const zoomFollowMinPaddingPxRef = useRef(zoomFollowMinPaddingPx); + // Cursor smoothing refs + const cursorSmoothingRef = useRef(cursorSmoothing); + const quadraticStrengthRef = useRef(quadraticSmoothingStrength); + const end2endParamsRefLocal = useRef(end2endParams); + + useEffect(() => { cursorSmoothingRef.current = cursorSmoothing; }, [cursorSmoothing]); + useEffect(() => { quadraticStrengthRef.current = quadraticSmoothingStrength; }, [quadraticSmoothingStrength]); + useEffect(() => { end2endParamsRefLocal.current = end2endParams; }, [end2endParams]); + + useEffect(() => { zoomFollowEnabledRef.current = zoomFollowEnabled; }, [zoomFollowEnabled]); + useEffect(() => { zoomFollowModeRef.current = zoomFollowMode as 'center' | 'anchor'; }, [zoomFollowMode]); + useEffect(() => { zoomFollowDelayMsRef.current = zoomFollowDelayMs; }, [zoomFollowDelayMs]); + useEffect(() => { zoomFollowMinPaddingPxRef.current = zoomFollowMinPaddingPx; }, [zoomFollowMinPaddingPx]); + + // Reset anchor when selected zoom changes (start fresh anchoring) + useEffect(() => { + followAnchorRef.current = null; + }, [selectedZoomId]); + // Also watch global fallback values (in case parent didn't wire up callbacks) + useEffect(() => { + try { + if ((window as any).__openscreen_zoomFollowEnabled !== undefined) { + zoomFollowEnabledRef.current = Boolean((window as any).__openscreen_zoomFollowEnabled); + } + if ((window as any).__openscreen_zoomFollowMode) { + zoomFollowModeRef.current = (window as any).__openscreen_zoomFollowMode; + } + if ((window as any).__openscreen_zoomFollowDelayMs !== undefined) { + zoomFollowDelayMsRef.current = Number((window as any).__openscreen_zoomFollowDelayMs); + } + if ((window as any).__openscreen_zoomFollowMinPaddingPx !== undefined) { + zoomFollowMinPaddingPxRef.current = Number((window as any).__openscreen_zoomFollowMinPaddingPx); + } + } catch {} + }, []); + + // When follow is enabled or mode changes to 'center', snap the camera to the + // smoothed cursor position immediately and clear any anchor so the ticker will + // continue following the cursor using the configured smoothing mode. + useEffect(() => { + if (!pixiReady || !videoReady) return; + // Only run when parent props or global mode enable center-follow. + const shouldSnap = + Boolean(zoomFollowEnabledRef.current || (typeof window !== 'undefined' && (window as any).__openscreen_zoomFollowEnabled)) && + (zoomFollowModeRef.current === 'center' || (typeof window !== 'undefined' && (window as any).__openscreen_zoomFollowMode === 'center')); + if (!shouldSnap) return; + + try { + if (!cursorTrack || !cursorTrack.events || cursorTrack.events.length === 0) return; + + const events = cursorTrack.events; + const offsetFromStyle = cursorTrack.style?.offsetMs ?? DEFAULT_CURSOR_STYLE.offsetMs ?? 0; + const playheadMs = Math.round(currentTimeRef.current) + offsetFromStyle; + const lastIdx = findLastIndex(events, playheadMs); + if (lastIdx < 0) return; + + // Interpolate between events to get precise normalized position + let nx = events[lastIdx].nx; + let ny = events[lastIdx].ny; + const nextEv = events[lastIdx + 1]; + const curEv = events[lastIdx]; + if (nextEv && nextEv.tMs > curEv.tMs) { + const frac = Math.max(0, Math.min(1, (playheadMs - curEv.tMs) / (nextEv.tMs - curEv.tMs))); + nx = curEv.nx + (nextEv.nx - curEv.nx) * frac; + ny = curEv.ny + (nextEv.ny - curEv.ny) * frac; + } + + const stagePt = getStageCoordsFromNormalized(nx, ny); + const stageSize = stageSizeRef.current; + if (!stagePt || !stageSize.width || !stageSize.height) return; + + const smoothingMode = cursorSmoothingRef.current || 'none'; + let targetCx = clamp01(stagePt.stageX / stageSize.width); + let targetCy = clamp01(stagePt.stageY / stageSize.height); + + if (smoothingMode === 'end2end' && end2endParamsRefLocal.current) { + const displayEventsForCursor: { tMs: number; x: number; y: number; kind: any; dragging: boolean }[] = []; + for (let i = 0; i < events.length; i += 1) { + const ev = events[i]; + const pos = getStageCoordsFromNormalized(ev.nx, ev.ny); + if (!pos) continue; + displayEventsForCursor.push({ + tMs: ev.tMs, + x: pos.stageX + (cursorTrack.style?.offsetX ?? DEFAULT_CURSOR_STYLE.offsetX ?? 0), + y: pos.stageY + (cursorTrack.style?.offsetY ?? DEFAULT_CURSOR_STYLE.offsetY ?? 0), + kind: ev.kind, + dragging: ev.dragging, + }); + } + const pausePoints = extractPausePointsFromDisplayEvents(displayEventsForCursor, end2endParamsRefLocal.current); + const arrivalFrac = typeof end2endParamsRefLocal.current.arrivalFraction === 'number' ? end2endParamsRefLocal.current.arrivalFraction : 1.0; + const evaluated = evaluatePositionOnCRByTime(pausePoints, playheadMs, arrivalFrac); + if (evaluated) { + targetCx = clamp01(evaluated.x / stageSize.width); + targetCy = clamp01(evaluated.y / stageSize.height); + } + } else if (smoothingMode === 'quadratic') { + const strength = typeof quadraticStrengthRef.current === 'number' ? quadraticStrengthRef.current : 0.5; + const windowSize = Math.max(1, Math.round(1 + strength * 6)); + const startIdx = Math.max(0, lastIdx - windowSize + 1); + let sumX = 0; + let sumY = 0; + let cnt = 0; + for (let i = startIdx; i <= lastIdx; i += 1) { + const ev = events[i]; + const pos = getStageCoordsFromNormalized(ev.nx, ev.ny); + if (!pos) continue; + const w = 1 + (i - startIdx); + sumX += pos.stageX * w; + sumY += pos.stageY * w; + cnt += w; + } + if (cnt > 0) { + const avgX = sumX / cnt; + const avgY = sumY / cnt; + targetCx = clamp01(avgX / stageSize.width); + targetCy = clamp01(avgY / stageSize.height); + } + } + + // Clear any anchor and snap animation state to the smoothed cursor target so + // ticker will continue to update from there. + followAnchorRef.current = null; + animationStateRef.current.focusX = targetCx; + animationStateRef.current.focusY = targetCy; + + // Immediately apply transform so user sees the snap without waiting a tick. + const cameraContainer = cameraContainerRef.current; + if (cameraContainer) { + applyZoomTransform({ + cameraContainer, + blurFilter: blurFilterRef.current, + stageSize: stageSizeRef.current, + baseMask: baseMaskRef.current, + zoomScale: animationStateRef.current.scale, + focusX: animationStateRef.current.focusX, + focusY: animationStateRef.current.focusY, + motionIntensity: 0, + isPlaying: isPlayingRef.current, + motionBlurEnabled: motionBlurEnabledRef.current, + }); + } + } catch (err) { + // swallow; this is an opportunistic snap + } + }, [pixiReady, videoReady]); + useEffect(() => { isPlayingRef.current = isPlaying; }, [isPlaying]); @@ -432,6 +806,344 @@ 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 (!cursorEnabled || !cursorTrack || cursorTrack.events.length === 0) { + return; + } + + // Get layout information to properly map cursor coordinates + const maskRect = baseMaskRef.current; + const cropBounds = cropBoundsRef.current; + + // Get current zoom state + const animationState = animationStateRef.current; + const zoomScale = animationState.scale; + + // When zoom is active, video is displayed full-screen, so use full overlay area + // Otherwise, use maskRect (which represents the cropped/scaled video area) + const displayArea = (zoomScale > 1) + ? { x: 0, y: 0, width, height } // Full overlay when zoomed + : (maskRect.width > 0 && maskRect.height > 0 + ? maskRect + : { x: 0, y: 0, width, height }); + + const events = cursorTrack.events; + const offsetFromStyle = cursorTrack.style?.offsetMs ?? DEFAULT_CURSOR_STYLE.offsetMs ?? 0; + const styleOffsetX = cursorTrack.style?.offsetX ?? DEFAULT_CURSOR_STYLE.offsetX ?? 0; + const styleOffsetY = cursorTrack.style?.offsetY ?? DEFAULT_CURSOR_STYLE.offsetY ?? 0; + const playheadMs = Math.round(currentTime * 1000) + offsetFromStyle; + 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) => { + const lockedDims = lockedVideoDimensionsRef.current; + // If we don't have locked video dimensions, fallback to simple mapping into displayArea + if (!lockedDims || lockedDims.width === 0 || lockedDims.height === 0) { + 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 coords to full-video pixel coordinates + const videoX = nx * fullVideoWidth; + const videoY = ny * fullVideoHeight; + + // If there is a crop and the point is outside the cropped bounds, skip drawing + if (cropBounds.endX > cropBounds.startX && cropBounds.endY > cropBounds.startY) { + if (videoX < cropBounds.startX || videoX > cropBounds.endX || + videoY < cropBounds.startY || videoY > cropBounds.endY) { + return null; + } + } + + // Map video pixel to stage coordinates using the same base sprite transform + // stage = baseOffset + videoPixel * baseScale + const baseScale = baseScaleRef.current; + const baseOffset = baseOffsetRef.current; + const stageSize = stageSizeRef.current; + + if (!stageSize.width || !stageSize.height || baseScale <= 0) { + // Fallback to displayArea mapping if stage info not ready + const displayX = displayArea.x + nx * displayArea.width; + const displayY = displayArea.y + ny * displayArea.height; + return { x: displayX, y: displayY }; + } + + const stageX = baseOffset.x + videoX * baseScale; + const stageY = baseOffset.y + videoY * baseScale; + + // Apply camera transform used by Pixi: scale about the focus then translate so focus is centered. + const focusX = animationState.focusX; + const focusY = animationState.focusY; + const zoom = zoomScale; + + const focusStagePxX = focusX * stageSize.width; + const focusStagePxY = focusY * stageSize.height; + + const stageCenterX = stageSize.width / 2; + const stageCenterY = stageSize.height / 2; + + const screenX = stageCenterX + (stageX - focusStagePxX) * zoom; + const screenY = stageCenterY + (stageY - focusStagePxY) * zoom; + + return { x: screenX, y: screenY }; + }; + + // Compute current cursor position. For end2end mode we derive position + // from the detected endpoints (straight-line interpolation by time). + const smoothing = cursorSmoothing || 'none'; + let x: number; + let y: number; + let dragging = false; + + if (smoothing === 'end2end' && end2endParams) { + // Build display-space move events from the ENTIRE track (not limited to lastIndex). + // Pause points require knowledge of subsequent motion beginnings, so we must + // analyze the full event stream to correctly identify pause points. + const displayEventsForCursor: { tMs: number; x: number; y: number; kind: any; dragging: boolean }[] = []; + for (let i = 0; i < events.length; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; + displayEventsForCursor.push({ tMs: ev.tMs, x: pos.x + styleOffsetX, y: pos.y + styleOffsetY, kind: ev.kind, dragging: ev.dragging }); + } + const pausePoints = extractPausePointsFromDisplayEvents(displayEventsForCursor, end2endParams); + const arrivalFrac = typeof end2endParams.arrivalFraction === 'number' ? end2endParams.arrivalFraction : 1.0; + const pos = evaluatePositionOnCRByTime(pausePoints, playheadMs, arrivalFrac); + if (!pos) return; + x = pos.x; + y = pos.y; + dragging = false; + } else { + const currentEvent = events[lastIndex]; + const displayPos = normalizeToDisplay(currentEvent.nx, currentEvent.ny); + if (!displayPos) return; // Coordinate is outside visible area + displayPos.x += styleOffsetX; + displayPos.y += styleOffsetY; + x = displayPos.x; + y = displayPos.y; + dragging = currentEvent.dragging; + } + + const baseSize = Math.max(6, cursorTrack.style.sizePx); + // Apply zoom scale to cursor size so it scales with the video + const cursorSize = (dragging ? baseSize * 1.1 : baseSize) * zoomScale; + + // If playback is paused, draw the full cursor path so user can inspect smoothing + const isPlaying = isPlayingRef.current; + let trailStartIndex = 0; + + if (!isPlaying) { + // Draw full path (or endpoints) when paused so user can inspect smoothing + if (smoothing === 'end2end' && end2endParams) { + // Build display-space move events + const displayEvents: { tMs: number; x: number; y: number; kind: any; dragging: boolean }[] = []; + for (let i = 0; i < events.length; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; + displayEvents.push({ tMs: ev.tMs, x: pos.x + styleOffsetX, y: pos.y + styleOffsetY, kind: ev.kind, dragging: ev.dragging }); + } + const pausePoints = extractPausePointsFromDisplayEvents(displayEvents, end2endParams); + if (pausePoints.length >= 2) { + const sampled = sampleCRPath(pausePoints, 12); + if (sampled.length >= 2) { + ctx.beginPath(); + ctx.moveTo(sampled[0].x, sampled[0].y); + for (let k = 1; k < sampled.length; k += 1) { + ctx.lineTo(sampled[k].x, sampled[k].y); + } + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; + ctx.lineWidth = Math.max(1, baseSize * 0.08) * zoomScale; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + } + } else { + // Draw full path of all events (visible ones) + const pts: { x: number; y: number }[] = []; + for (let i = 0; i < events.length; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; + pts.push({ x: pos.x + styleOffsetX, y: pos.y + styleOffsetY }); + } + + if (pts.length >= 2) { + ctx.beginPath(); + if (smoothing === 'none') { + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i += 1) { + ctx.lineTo(pts[i].x, pts[i].y); + } + } else if (smoothing === 'quadratic') { + const strength = typeof quadraticSmoothingStrength === 'number' ? quadraticSmoothingStrength : 0.5; + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i += 1) { + const prev = pts[i - 1]; + const cur = pts[i]; + const midX = (prev.x + cur.x) / 2; + const midY = (prev.y + cur.y) / 2; + const ctrlX = prev.x + (cur.x - prev.x) * strength; + const ctrlY = prev.y + (cur.y - prev.y) * strength; + ctx.quadraticCurveTo(ctrlX, ctrlY, midX, midY); + } + ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + } + + ctx.strokeStyle = 'rgba(255,255,255,0.22)'; + ctx.lineWidth = Math.max(1, baseSize * 0.08) * zoomScale; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + } + } else { + const trailStartMs = Math.max(0, playheadMs - CURSOR_TRAIL_MS); + trailStartIndex = Math.min(lastIndex, findFirstIndex(events, trailStartMs)); + + if (lastIndex - trailStartIndex >= 1) { + // Collect visible points for trail drawing + if (smoothing === 'end2end' && end2endParams) { + // Build display-space events for the trail window + const displayEvents: { tMs: number; x: number; y: number; kind: any; dragging: boolean }[] = []; + for (let i = trailStartIndex; i <= lastIndex; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; + displayEvents.push({ tMs: ev.tMs, x: pos.x + styleOffsetX, y: pos.y + styleOffsetY, kind: ev.kind, dragging: ev.dragging }); + } + const pausePoints = extractPausePointsFromDisplayEvents(displayEvents, end2endParams); + if (pausePoints.length >= 2) { + const sampled = sampleCRPath(pausePoints, 10); + if (sampled.length >= 2) { + ctx.beginPath(); + ctx.moveTo(sampled[0].x, sampled[0].y); + for (let k = 1; k < sampled.length; k += 1) { + ctx.lineTo(sampled[k].x, sampled[k].y); + } + ctx.strokeStyle = dragging ? 'rgba(52,178,123,0.55)' : 'rgba(255,255,255,0.35)'; + ctx.lineWidth = Math.max(1, baseSize * 0.12) * zoomScale; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + } + } else { + // Collect visible points for trail drawing + const pts: { x: number; y: number }[] = []; + for (let i = trailStartIndex; i <= lastIndex; i += 1) { + const ev = events[i]; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; + pts.push({ x: pos.x + styleOffsetX, y: pos.y + styleOffsetY }); + } + + if (pts.length >= 2) { + ctx.beginPath(); + if (smoothing === 'none') { + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i += 1) { + ctx.lineTo(pts[i].x, pts[i].y); + } + } else if (smoothing === 'quadratic') { + // Quadratic smoothing using midpoints with configurable strength + const strength = typeof quadraticSmoothingStrength === 'number' ? quadraticSmoothingStrength : 0.5; + ctx.moveTo(pts[0].x, pts[0].y); + for (let i = 1; i < pts.length; i += 1) { + const prev = pts[i - 1]; + const cur = pts[i]; + const midX = (prev.x + cur.x) / 2; + const midY = (prev.y + cur.y) / 2; + const ctrlX = prev.x + (cur.x - prev.x) * strength; + const ctrlY = prev.y + (cur.y - prev.y) * strength; + ctx.quadraticCurveTo(ctrlX, ctrlY, midX, midY); + } + // Ensure curve reaches last point + ctx.lineTo(pts[pts.length - 1].x, pts[pts.length - 1].y); + } + + ctx.strokeStyle = dragging ? 'rgba(52,178,123,0.55)' : 'rgba(255,255,255,0.35)'; + ctx.lineWidth = Math.max(1, baseSize * 0.12) * zoomScale; + 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) * zoomScale; + const pos = normalizeToDisplay(ev.nx, ev.ny); + if (!pos) continue; // Skip clicks outside visible area + pos.x += styleOffsetX; + pos.y += styleOffsetY; + + ctx.beginPath(); + ctx.strokeStyle = `rgba(255,255,255,${alpha * 0.7})`; + ctx.lineWidth = Math.max(1, baseSize * 0.08) * zoomScale; + 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, cursorEnabled, cursorSmoothing, quadraticSmoothingStrength, end2endParams, CURSOR_TRAIL_MS, CURSOR_CLICK_MS, resizeCursorCanvas, cropRegion, padding]); + // Redraw cursor overlay when enabled/smoothing changes + useEffect(() => { const container = containerRef.current; if (!container) return; @@ -650,6 +1362,155 @@ const VideoPlayback = forwardRef(({ }; } + // Apply zoom-follow behavior if enabled (read from refs or global fallback) + const followEnabled = Boolean(zoomFollowEnabledRef.current || (typeof window !== 'undefined' && (window as any).__openscreen_zoomFollowEnabled)); + // Only enable follow when parent/global follow is enabled AND there is an active + // zoom region (strength > 0). This ensures we only follow during zoom and its + // fade-in/fade-out period. + if (followEnabled && region && strength > 0 && cursorTrack && cursorTrack.events && cursorTrack.events.length > 0) { + try { + // Debug logging when enabled + try { + if ((window as any).__openscreen_debugZoomFollow) { + console.debug('[zoomFollow] enabled', { followEnabled, zoomFollowMode: zoomFollowModeRef.current, selectedId: selectedZoomIdRef.current, region, strength, targetScaleFactor }); + } + } catch {} + const events = cursorTrack.events; + const offsetFromStyle = cursorTrack.style?.offsetMs ?? DEFAULT_CURSOR_STYLE.offsetMs ?? 0; + const playheadMs = Math.round(currentTimeRef.current) + offsetFromStyle; + + // Compute a smoothed follow target even if playhead is outside immediate event bounds. + const stageSize = stageSizeRef.current; + if (events.length > 0 && stageSize.width && stageSize.height) { + // Default followTarget is the current region targetFocus + let followTarget = { cx: targetFocus.cx, cy: targetFocus.cy }; + const smoothingMode = cursorSmoothingRef.current || 'none'; + + if (smoothingMode === 'end2end' && end2endParamsRefLocal.current) { + const displayEventsForCursor: { tMs: number; x: number; y: number; kind: any; dragging: boolean }[] = []; + for (let i = 0; i < events.length; i += 1) { + const ev = events[i]; + const pos = getStageCoordsFromNormalized(ev.nx, ev.ny); + if (!pos) continue; + displayEventsForCursor.push({ + tMs: ev.tMs, + x: pos.stageX + (cursorTrack.style?.offsetX ?? DEFAULT_CURSOR_STYLE.offsetX ?? 0), + y: pos.stageY + (cursorTrack.style?.offsetY ?? DEFAULT_CURSOR_STYLE.offsetY ?? 0), + kind: ev.kind, + dragging: ev.dragging, + }); + } + const pausePoints = extractPausePointsFromDisplayEvents(displayEventsForCursor, end2endParamsRefLocal.current); + const arrivalFrac = typeof end2endParamsRefLocal.current.arrivalFraction === 'number' ? end2endParamsRefLocal.current.arrivalFraction : 1.0; + const evaluated = evaluatePositionOnCRByTime(pausePoints, playheadMs, arrivalFrac); + if (evaluated) { + followTarget = { cx: clamp01(evaluated.x / stageSize.width), cy: clamp01(evaluated.y / stageSize.height) }; + } else { + // Fallback to last-known event position + const lastIdx = findLastIndex(events, playheadMs); + if (lastIdx >= 0) { + const pos = getStageCoordsFromNormalized(events[lastIdx].nx, events[lastIdx].ny); + if (pos) followTarget = { cx: clamp01(pos.stageX / stageSize.width), cy: clamp01(pos.stageY / stageSize.height) }; + } + } + } else if (smoothingMode === 'quadratic') { + const lastIdx = findLastIndex(events, playheadMs); + const strength = typeof quadraticStrengthRef.current === 'number' ? quadraticStrengthRef.current : 0.5; + const windowSize = Math.max(1, Math.round(1 + strength * 6)); + const startIdx = Math.max(0, (lastIdx >= 0 ? lastIdx : events.length - 1) - windowSize + 1); + let sumX = 0; + let sumY = 0; + let cnt = 0; + for (let i = startIdx; i < events.length && i <= startIdx + windowSize; i += 1) { + const ev = events[i]; + const pos = getStageCoordsFromNormalized(ev.nx, ev.ny); + if (!pos) continue; + const w = 1 + (i - startIdx); + sumX += pos.stageX * w; + sumY += pos.stageY * w; + cnt += w; + } + if (cnt > 0) { + const avgX = sumX / cnt; + const avgY = sumY / cnt; + followTarget = { cx: clamp01(avgX / stageSize.width), cy: clamp01(avgY / stageSize.height) }; + } else { + const lastIdx2 = findLastIndex(events, playheadMs); + if (lastIdx2 >= 0) { + const pos = getStageCoordsFromNormalized(events[lastIdx2].nx, events[lastIdx2].ny); + if (pos) followTarget = { cx: clamp01(pos.stageX / stageSize.width), cy: clamp01(pos.stageY / stageSize.height) }; + } + } + } else { + // none: use interpolated normalized position if possible + const lastIdx = findLastIndex(events, playheadMs); + if (lastIdx >= 0) { + let nx = events[lastIdx].nx; + let ny = events[lastIdx].ny; + const nextEv = events[lastIdx + 1]; + const curEv = events[lastIdx]; + if (nextEv && nextEv.tMs > curEv.tMs) { + const frac = Math.max(0, Math.min(1, (playheadMs - curEv.tMs) / (nextEv.tMs - curEv.tMs))); + nx = curEv.nx + (nextEv.nx - curEv.nx) * frac; + ny = curEv.ny + (nextEv.ny - curEv.ny) * frac; + } + const pos = getStageCoordsFromNormalized(nx, ny); + if (pos) followTarget = { cx: clamp01(pos.stageX / stageSize.width), cy: clamp01(pos.stageY / stageSize.height) }; + } + } + + const followMode = zoomFollowModeRef.current || (typeof window !== 'undefined' && (window as any).__openscreen_zoomFollowMode) || 'center'; + if (followMode === 'center') { + targetFocus = followTarget; + } else { + // Anchor mode: adjust anchor when cursor near edge + if (!followAnchorRef.current) { + followAnchorRef.current = { cx: followTarget.cx, cy: followTarget.cy }; + } + const anchor = followAnchorRef.current; + const anchorStageX = anchor.cx * stageSize.width; + const anchorStageY = anchor.cy * stageSize.height; + const zoom = targetScaleFactor; + const viewW = Math.max(1, stageSize.width / zoom); + const viewH = Math.max(1, stageSize.height / zoom); + const pad = zoomFollowMinPaddingPxRef.current ?? (typeof window !== 'undefined' ? (window as any).__openscreen_zoomFollowMinPaddingPx ?? 24 : 24); + + let newAnchorStageX = anchorStageX; + let newAnchorStageY = anchorStageY; + const cursorStageX = followTarget.cx * stageSize.width; + const cursorStageY = followTarget.cy * stageSize.height; + + const left = anchorStageX - viewW / 2 + pad; + const right = anchorStageX + viewW / 2 - pad; + if (cursorStageX < left) { + newAnchorStageX = cursorStageX + viewW / 2 - pad; + } else if (cursorStageX > right) { + newAnchorStageX = cursorStageX - viewW / 2 + pad; + } + + const top = anchorStageY - viewH / 2 + pad; + const bottom = anchorStageY + viewH / 2 - pad; + if (cursorStageY < top) { + newAnchorStageY = cursorStageY + viewH / 2 - pad; + } else if (cursorStageY > bottom) { + newAnchorStageY = cursorStageY - viewH / 2 + pad; + } + + const clampedX = clamp01(newAnchorStageX / stageSize.width); + const clampedY = clamp01(newAnchorStageY / stageSize.height); + followAnchorRef.current = { cx: clampedX, cy: clampedY }; + targetFocus = { cx: clampedX, cy: clampedY }; + } + } + } catch (err) { + // swallow errors in optional follow logic + } + } else { + // Not following right now: clear any existing anchor so we don't persist an + // anchored follow once the zoom region exits. + followAnchorRef.current = null; + } + const state = animationStateRef.current; const prevScale = state.scale; @@ -867,6 +1728,7 @@ const VideoPlayback = forwardRef(({ /> )); })()} + )}