mirror of
https://github.com/webadderallorg/Recordly.git
synced 2026-09-27 16:25:35 +00:00
Merge pull request #38 from kbdevs/feat/cursor-sway
feat(editor): add cursor sway, HUD capture controls, and persistent prefs
This commit is contained in:
Vendored
+227
-122
@@ -1,122 +1,227 @@
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
|
||||
switchToEditor: () => Promise<void>
|
||||
openSourceSelector: () => Promise<void>
|
||||
selectSource: (source: any) => Promise<any>
|
||||
getSelectedSource: () => Promise<any>
|
||||
startNativeScreenRecording: (
|
||||
source: any,
|
||||
options?: {
|
||||
capturesSystemAudio?: boolean
|
||||
capturesMicrophone?: boolean
|
||||
microphoneDeviceId?: string
|
||||
microphoneLabel?: string
|
||||
}
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
stopNativeScreenRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
startFfmpegRecording: (source: any) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
stopFfmpegRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>
|
||||
readLocalFile: (filePath: string) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{ success: boolean; samples: CursorTelemetryPoint[]; message?: string; error?: string }>
|
||||
getSystemCursorAssets: () => Promise<{ success: boolean; cursors: Record<string, SystemCursorAsset>; error?: string }>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void
|
||||
onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void
|
||||
onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }>
|
||||
requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }>
|
||||
getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }>
|
||||
openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>
|
||||
openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean; error?: string }>
|
||||
loadProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
|
||||
loadCurrentProjectFile: () => Promise<{ success: boolean; path?: string; project?: unknown; message?: string; canceled?: boolean; error?: string }>
|
||||
onMenuLoadProject: (callback: () => void) => () => void
|
||||
onMenuSaveProject: (callback: () => void) => () => void
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void
|
||||
getPlatform: () => Promise<string>
|
||||
revealInFolder: (filePath: string) => Promise<{ success: boolean; error?: string; message?: string }>,
|
||||
openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>,
|
||||
getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }>
|
||||
chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }>
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void
|
||||
isWgcAvailable: () => Promise<{ available: boolean }>
|
||||
muxWgcRecording: () => Promise<{ success: boolean; path?: string; message?: string; error?: string }>
|
||||
/** Hide the OS cursor before browser capture starts. */
|
||||
hideOsCursor: () => Promise<{ success: boolean }>
|
||||
/** Countdown timer before recording */
|
||||
getCountdownDelay: () => Promise<{ success: boolean; delay: number }>
|
||||
setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>
|
||||
startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>
|
||||
cancelCountdown: () => Promise<{ success: boolean }>
|
||||
onCountdownTick: (callback: (seconds: number) => void) => () => void
|
||||
}
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string
|
||||
name: string
|
||||
display_id: string
|
||||
thumbnail: string | null
|
||||
appIcon: string | null
|
||||
originalName?: string
|
||||
sourceType?: 'screen' | 'window'
|
||||
appName?: string
|
||||
windowTitle?: string
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number
|
||||
cx: number
|
||||
cy: number
|
||||
interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup'
|
||||
cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed'
|
||||
}
|
||||
|
||||
interface SystemCursorAsset {
|
||||
dataUrl: string
|
||||
hotspotX: number
|
||||
hotspotY: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/// <reference types="vite-plugin-electron/electron-env" />
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
/**
|
||||
* The built directory structure
|
||||
*
|
||||
* ```tree
|
||||
* ├─┬─┬ dist
|
||||
* │ │ └── index.html
|
||||
* │ │
|
||||
* │ ├─┬ dist-electron
|
||||
* │ │ ├── main.js
|
||||
* │ │ └── preload.js
|
||||
* │
|
||||
* ```
|
||||
*/
|
||||
APP_ROOT: string;
|
||||
/** /dist/ or /public/ */
|
||||
VITE_PUBLIC: string;
|
||||
}
|
||||
}
|
||||
|
||||
// Used in Renderer process, expose in `preload.ts`
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>;
|
||||
switchToEditor: () => Promise<void>;
|
||||
openSourceSelector: () => Promise<void>;
|
||||
selectSource: (source: any) => Promise<any>;
|
||||
getSelectedSource: () => Promise<any>;
|
||||
startNativeScreenRecording: (
|
||||
source: any,
|
||||
options?: {
|
||||
capturesSystemAudio?: boolean;
|
||||
capturesMicrophone?: boolean;
|
||||
microphoneDeviceId?: string;
|
||||
microphoneLabel?: string;
|
||||
},
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
|
||||
stopNativeScreenRecording: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
startFfmpegRecording: (
|
||||
source: any,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; error?: string }>;
|
||||
stopFfmpegRecording: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
storeRecordedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
getRecordedVideoPath: () => Promise<{ success: boolean; path?: string; message?: string }>;
|
||||
readLocalFile: (
|
||||
filePath: string,
|
||||
) => Promise<{ success: boolean; data?: Uint8Array; error?: string }>;
|
||||
setRecordingState: (recording: boolean) => Promise<void>;
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean;
|
||||
samples: CursorTelemetryPoint[];
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getSystemCursorAssets: () => Promise<{
|
||||
success: boolean;
|
||||
cursors: Record<string, SystemCursorAsset>;
|
||||
error?: string;
|
||||
}>;
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void;
|
||||
onRecordingStateChanged: (
|
||||
callback: (state: { recording: boolean; sourceName: string }) => void,
|
||||
) => () => void;
|
||||
onRecordingInterrupted: (
|
||||
callback: (state: { reason: string; message: string }) => void,
|
||||
) => () => void;
|
||||
onCursorStateChanged: (
|
||||
callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void,
|
||||
) => () => void;
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>;
|
||||
getAccessibilityPermissionStatus: () => Promise<{
|
||||
success: boolean;
|
||||
trusted: boolean;
|
||||
prompted: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
requestAccessibilityPermission: () => Promise<{
|
||||
success: boolean;
|
||||
trusted: boolean;
|
||||
prompted: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
getScreenRecordingPermissionStatus: () => Promise<{
|
||||
success: boolean;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>;
|
||||
openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>;
|
||||
openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>;
|
||||
saveExportedVideo: (
|
||||
videoData: ArrayBuffer,
|
||||
fileName: string,
|
||||
) => Promise<{ success: boolean; path?: string; message?: string; canceled?: boolean }>;
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
openAudioFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>;
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>;
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>;
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>;
|
||||
saveProjectFile: (
|
||||
projectData: unknown,
|
||||
suggestedName?: string,
|
||||
existingProjectPath?: string,
|
||||
) => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
loadCurrentProjectFile: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
project?: unknown;
|
||||
message?: string;
|
||||
canceled?: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
onMenuLoadProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProject: (callback: () => void) => () => void;
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void;
|
||||
getPlatform: () => Promise<string>;
|
||||
revealInFolder: (
|
||||
filePath: string,
|
||||
) => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
openRecordingsFolder: () => Promise<{ success: boolean; error?: string; message?: string }>;
|
||||
getRecordingsDirectory: () => Promise<{
|
||||
success: boolean;
|
||||
path: string;
|
||||
isDefault: boolean;
|
||||
error?: string;
|
||||
}>;
|
||||
chooseRecordingsDirectory: () => Promise<{
|
||||
success: boolean;
|
||||
canceled?: boolean;
|
||||
path?: string;
|
||||
isDefault?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
getShortcuts: () => Promise<Record<string, unknown> | null>;
|
||||
saveShortcuts: (shortcuts: unknown) => Promise<{ success: boolean; error?: string }>;
|
||||
hudOverlayHide: () => void;
|
||||
hudOverlayClose: () => void;
|
||||
getHudOverlayCaptureProtection: () => Promise<{ success: boolean; enabled: boolean }>;
|
||||
setHudOverlayCaptureProtection: (
|
||||
enabled: boolean,
|
||||
) => Promise<{ success: boolean; enabled: boolean }>;
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void;
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void;
|
||||
isWgcAvailable: () => Promise<{ available: boolean }>;
|
||||
muxWgcRecording: () => Promise<{
|
||||
success: boolean;
|
||||
path?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}>;
|
||||
/** Hide the OS cursor before browser capture starts. */
|
||||
hideOsCursor: () => Promise<{ success: boolean }>;
|
||||
/** Countdown timer before recording */
|
||||
getCountdownDelay: () => Promise<{ success: boolean; delay: number }>;
|
||||
setCountdownDelay: (delay: number) => Promise<{ success: boolean; error?: string }>;
|
||||
startCountdown: (seconds: number) => Promise<{ success: boolean; cancelled?: boolean }>;
|
||||
cancelCountdown: () => Promise<{ success: boolean }>;
|
||||
onCountdownTick: (callback: (seconds: number) => void) => () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
display_id: string;
|
||||
thumbnail: string | null;
|
||||
appIcon: string | null;
|
||||
originalName?: string;
|
||||
sourceType?: "screen" | "window";
|
||||
appName?: string;
|
||||
windowTitle?: string;
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
interactionType?: "move" | "click" | "double-click" | "right-click" | "middle-click" | "mouseup";
|
||||
cursorType?:
|
||||
| "arrow"
|
||||
| "text"
|
||||
| "pointer"
|
||||
| "crosshair"
|
||||
| "open-hand"
|
||||
| "closed-hand"
|
||||
| "resize-ew"
|
||||
| "resize-ns"
|
||||
| "not-allowed";
|
||||
}
|
||||
|
||||
interface SystemCursorAsset {
|
||||
dataUrl: string;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+211
-198
@@ -1,199 +1,212 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send('hud-overlay-hide');
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send('hud-overlay-close');
|
||||
},
|
||||
getAssetBasePath: async () => {
|
||||
// ask main process for the correct base path (production vs dev)
|
||||
return await ipcRenderer.invoke('get-asset-base-path')
|
||||
},
|
||||
readLocalFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke('read-local-file', filePath)
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke('get-sources', opts)
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke('switch-to-editor')
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke('open-source-selector')
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke('select-source', source)
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke('get-selected-source')
|
||||
},
|
||||
startNativeScreenRecording: (
|
||||
source: any,
|
||||
options?: {
|
||||
capturesSystemAudio?: boolean
|
||||
capturesMicrophone?: boolean
|
||||
microphoneDeviceId?: string
|
||||
microphoneLabel?: string
|
||||
},
|
||||
) => {
|
||||
return ipcRenderer.invoke('start-native-screen-recording', source, options)
|
||||
},
|
||||
stopNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke('stop-native-screen-recording')
|
||||
},
|
||||
startFfmpegRecording: (source: any) => {
|
||||
return ipcRenderer.invoke('start-ffmpeg-recording', source)
|
||||
},
|
||||
stopFfmpegRecording: () => {
|
||||
return ipcRenderer.invoke('stop-ffmpeg-recording')
|
||||
},
|
||||
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('store-recorded-video', videoData, fileName)
|
||||
},
|
||||
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke('get-recorded-video-path')
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke('set-recording-state', recording)
|
||||
},
|
||||
setCursorScale: (scale: number) => {
|
||||
return ipcRenderer.invoke('set-cursor-scale', scale)
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke('get-cursor-telemetry', videoPath)
|
||||
},
|
||||
getSystemCursorAssets: () => {
|
||||
return ipcRenderer.invoke('get-system-cursor-assets')
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('stop-recording-from-tray', listener)
|
||||
return () => ipcRenderer.removeListener('stop-recording-from-tray', listener)
|
||||
},
|
||||
onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { recording: boolean; sourceName: string }) => callback(payload)
|
||||
ipcRenderer.on('recording-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('recording-state-changed', listener)
|
||||
},
|
||||
onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { reason: string; message: string }) => callback(payload)
|
||||
ipcRenderer.on('recording-interrupted', listener)
|
||||
return () => ipcRenderer.removeListener('recording-interrupted', listener)
|
||||
},
|
||||
onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { cursorType: CursorTelemetryPoint['cursorType'] }) => callback(payload)
|
||||
ipcRenderer.on('cursor-state-changed', listener)
|
||||
return () => ipcRenderer.removeListener('cursor-state-changed', listener)
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke('open-external-url', url)
|
||||
},
|
||||
getAccessibilityPermissionStatus: () => {
|
||||
return ipcRenderer.invoke('get-accessibility-permission-status')
|
||||
},
|
||||
requestAccessibilityPermission: () => {
|
||||
return ipcRenderer.invoke('request-accessibility-permission')
|
||||
},
|
||||
getScreenRecordingPermissionStatus: () => {
|
||||
return ipcRenderer.invoke('get-screen-recording-permission-status')
|
||||
},
|
||||
openScreenRecordingPreferences: () => {
|
||||
return ipcRenderer.invoke('open-screen-recording-preferences')
|
||||
},
|
||||
openAccessibilityPreferences: () => {
|
||||
return ipcRenderer.invoke('open-accessibility-preferences')
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke('save-exported-video', videoData, fileName)
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke('open-video-file-picker')
|
||||
},
|
||||
openAudioFilePicker: () => {
|
||||
return ipcRenderer.invoke('open-audio-file-picker')
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke('set-current-video-path', path)
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('get-current-video-path')
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke('clear-current-video-path')
|
||||
},
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
return ipcRenderer.invoke('save-project-file', projectData, suggestedName, existingProjectPath)
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke('load-project-file')
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke('load-current-project-file')
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('menu-load-project', listener)
|
||||
return () => ipcRenderer.removeListener('menu-load-project', listener)
|
||||
},
|
||||
onMenuSaveProject: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('menu-save-project', listener)
|
||||
return () => ipcRenderer.removeListener('menu-save-project', listener)
|
||||
},
|
||||
onMenuSaveProjectAs: (callback: () => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('menu-save-project-as', listener)
|
||||
return () => ipcRenderer.removeListener('menu-save-project-as', listener)
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke('get-platform')
|
||||
},
|
||||
revealInFolder: (filePath: string) => {
|
||||
return ipcRenderer.invoke('reveal-in-folder', filePath)
|
||||
},
|
||||
openRecordingsFolder: () => {
|
||||
return ipcRenderer.invoke('open-recordings-folder')
|
||||
},
|
||||
getRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke('get-recordings-directory')
|
||||
},
|
||||
chooseRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke('choose-recordings-directory')
|
||||
},
|
||||
getShortcuts: () => {
|
||||
return ipcRenderer.invoke('get-shortcuts')
|
||||
},
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke('save-shortcuts', shortcuts)
|
||||
},
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||
ipcRenderer.send('set-has-unsaved-changes', hasChanges)
|
||||
},
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
|
||||
const listener = async () => {
|
||||
await callback()
|
||||
ipcRenderer.send('save-before-close-done')
|
||||
}
|
||||
ipcRenderer.on('request-save-before-close', listener)
|
||||
return () => ipcRenderer.removeListener('request-save-before-close', listener)
|
||||
},
|
||||
isWgcAvailable: () => ipcRenderer.invoke('is-wgc-available'),
|
||||
muxWgcRecording: () => ipcRenderer.invoke('mux-wgc-recording'),
|
||||
// Cursor visibility control for cursor-free browser capture fallback
|
||||
hideOsCursor: () => ipcRenderer.invoke('hide-cursor'),
|
||||
// Countdown timer before recording
|
||||
getCountdownDelay: () => ipcRenderer.invoke('get-countdown-delay'),
|
||||
setCountdownDelay: (delay: number) => ipcRenderer.invoke('set-countdown-delay', delay),
|
||||
startCountdown: (seconds: number) => ipcRenderer.invoke('start-countdown', seconds),
|
||||
cancelCountdown: () => ipcRenderer.invoke('cancel-countdown'),
|
||||
onCountdownTick: (callback: (seconds: number) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, seconds: number) => callback(seconds)
|
||||
ipcRenderer.on('countdown-tick', listener)
|
||||
return () => ipcRenderer.removeListener('countdown-tick', listener)
|
||||
},
|
||||
})
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("electronAPI", {
|
||||
hudOverlayHide: () => {
|
||||
ipcRenderer.send("hud-overlay-hide");
|
||||
},
|
||||
hudOverlayClose: () => {
|
||||
ipcRenderer.send("hud-overlay-close");
|
||||
},
|
||||
getHudOverlayCaptureProtection: () => {
|
||||
return ipcRenderer.invoke("get-hud-overlay-capture-protection");
|
||||
},
|
||||
setHudOverlayCaptureProtection: (enabled: boolean) => {
|
||||
return ipcRenderer.invoke("set-hud-overlay-capture-protection", enabled);
|
||||
},
|
||||
getAssetBasePath: async () => {
|
||||
return await ipcRenderer.invoke("get-asset-base-path");
|
||||
},
|
||||
readLocalFile: (filePath: string) => {
|
||||
return ipcRenderer.invoke("read-local-file", filePath);
|
||||
},
|
||||
getSources: async (opts: Electron.SourcesOptions) => {
|
||||
return await ipcRenderer.invoke("get-sources", opts);
|
||||
},
|
||||
switchToEditor: () => {
|
||||
return ipcRenderer.invoke("switch-to-editor");
|
||||
},
|
||||
openSourceSelector: () => {
|
||||
return ipcRenderer.invoke("open-source-selector");
|
||||
},
|
||||
selectSource: (source: any) => {
|
||||
return ipcRenderer.invoke("select-source", source);
|
||||
},
|
||||
getSelectedSource: () => {
|
||||
return ipcRenderer.invoke("get-selected-source");
|
||||
},
|
||||
startNativeScreenRecording: (
|
||||
source: any,
|
||||
options?: {
|
||||
capturesSystemAudio?: boolean;
|
||||
capturesMicrophone?: boolean;
|
||||
microphoneDeviceId?: string;
|
||||
microphoneLabel?: string;
|
||||
},
|
||||
) => {
|
||||
return ipcRenderer.invoke("start-native-screen-recording", source, options);
|
||||
},
|
||||
stopNativeScreenRecording: () => {
|
||||
return ipcRenderer.invoke("stop-native-screen-recording");
|
||||
},
|
||||
startFfmpegRecording: (source: any) => {
|
||||
return ipcRenderer.invoke("start-ffmpeg-recording", source);
|
||||
},
|
||||
stopFfmpegRecording: () => {
|
||||
return ipcRenderer.invoke("stop-ffmpeg-recording");
|
||||
},
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("store-recorded-video", videoData, fileName);
|
||||
},
|
||||
getRecordedVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-recorded-video-path");
|
||||
},
|
||||
setRecordingState: (recording: boolean) => {
|
||||
return ipcRenderer.invoke("set-recording-state", recording);
|
||||
},
|
||||
setCursorScale: (scale: number) => {
|
||||
return ipcRenderer.invoke("set-cursor-scale", scale);
|
||||
},
|
||||
getCursorTelemetry: (videoPath?: string) => {
|
||||
return ipcRenderer.invoke("get-cursor-telemetry", videoPath);
|
||||
},
|
||||
getSystemCursorAssets: () => {
|
||||
return ipcRenderer.invoke("get-system-cursor-assets");
|
||||
},
|
||||
onStopRecordingFromTray: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("stop-recording-from-tray", listener);
|
||||
return () => ipcRenderer.removeListener("stop-recording-from-tray", listener);
|
||||
},
|
||||
onRecordingStateChanged: (
|
||||
callback: (state: { recording: boolean; sourceName: string }) => void,
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { recording: boolean; sourceName: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("recording-state-changed", listener);
|
||||
return () => ipcRenderer.removeListener("recording-state-changed", listener);
|
||||
},
|
||||
onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { reason: string; message: string },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("recording-interrupted", listener);
|
||||
return () => ipcRenderer.removeListener("recording-interrupted", listener);
|
||||
},
|
||||
onCursorStateChanged: (
|
||||
callback: (state: { cursorType: CursorTelemetryPoint["cursorType"] }) => void,
|
||||
) => {
|
||||
const listener = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
payload: { cursorType: CursorTelemetryPoint["cursorType"] },
|
||||
) => callback(payload);
|
||||
ipcRenderer.on("cursor-state-changed", listener);
|
||||
return () => ipcRenderer.removeListener("cursor-state-changed", listener);
|
||||
},
|
||||
openExternalUrl: (url: string) => {
|
||||
return ipcRenderer.invoke("open-external-url", url);
|
||||
},
|
||||
getAccessibilityPermissionStatus: () => {
|
||||
return ipcRenderer.invoke("get-accessibility-permission-status");
|
||||
},
|
||||
requestAccessibilityPermission: () => {
|
||||
return ipcRenderer.invoke("request-accessibility-permission");
|
||||
},
|
||||
getScreenRecordingPermissionStatus: () => {
|
||||
return ipcRenderer.invoke("get-screen-recording-permission-status");
|
||||
},
|
||||
openScreenRecordingPreferences: () => {
|
||||
return ipcRenderer.invoke("open-screen-recording-preferences");
|
||||
},
|
||||
openAccessibilityPreferences: () => {
|
||||
return ipcRenderer.invoke("open-accessibility-preferences");
|
||||
},
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => {
|
||||
return ipcRenderer.invoke("save-exported-video", videoData, fileName);
|
||||
},
|
||||
openVideoFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-video-file-picker");
|
||||
},
|
||||
openAudioFilePicker: () => {
|
||||
return ipcRenderer.invoke("open-audio-file-picker");
|
||||
},
|
||||
setCurrentVideoPath: (path: string) => {
|
||||
return ipcRenderer.invoke("set-current-video-path", path);
|
||||
},
|
||||
getCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("get-current-video-path");
|
||||
},
|
||||
clearCurrentVideoPath: () => {
|
||||
return ipcRenderer.invoke("clear-current-video-path");
|
||||
},
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => {
|
||||
return ipcRenderer.invoke("save-project-file", projectData, suggestedName, existingProjectPath);
|
||||
},
|
||||
loadProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-project-file");
|
||||
},
|
||||
loadCurrentProjectFile: () => {
|
||||
return ipcRenderer.invoke("load-current-project-file");
|
||||
},
|
||||
onMenuLoadProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-load-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-load-project", listener);
|
||||
},
|
||||
onMenuSaveProject: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project", listener);
|
||||
},
|
||||
onMenuSaveProjectAs: (callback: () => void) => {
|
||||
const listener = () => callback();
|
||||
ipcRenderer.on("menu-save-project-as", listener);
|
||||
return () => ipcRenderer.removeListener("menu-save-project-as", listener);
|
||||
},
|
||||
getPlatform: () => {
|
||||
return ipcRenderer.invoke("get-platform");
|
||||
},
|
||||
revealInFolder: (filePath: string) => {
|
||||
return ipcRenderer.invoke("reveal-in-folder", filePath);
|
||||
},
|
||||
openRecordingsFolder: () => {
|
||||
return ipcRenderer.invoke("open-recordings-folder");
|
||||
},
|
||||
getRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke("get-recordings-directory");
|
||||
},
|
||||
chooseRecordingsDirectory: () => {
|
||||
return ipcRenderer.invoke("choose-recordings-directory");
|
||||
},
|
||||
getShortcuts: () => {
|
||||
return ipcRenderer.invoke("get-shortcuts");
|
||||
},
|
||||
saveShortcuts: (shortcuts: unknown) => {
|
||||
return ipcRenderer.invoke("save-shortcuts", shortcuts);
|
||||
},
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => {
|
||||
ipcRenderer.send("set-has-unsaved-changes", hasChanges);
|
||||
},
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => {
|
||||
const listener = async () => {
|
||||
await callback();
|
||||
ipcRenderer.send("save-before-close-done");
|
||||
};
|
||||
ipcRenderer.on("request-save-before-close", listener);
|
||||
return () => ipcRenderer.removeListener("request-save-before-close", listener);
|
||||
},
|
||||
isWgcAvailable: () => ipcRenderer.invoke("is-wgc-available"),
|
||||
muxWgcRecording: () => ipcRenderer.invoke("mux-wgc-recording"),
|
||||
hideOsCursor: () => ipcRenderer.invoke("hide-cursor"),
|
||||
getCountdownDelay: () => ipcRenderer.invoke("get-countdown-delay"),
|
||||
setCountdownDelay: (delay: number) => ipcRenderer.invoke("set-countdown-delay", delay),
|
||||
startCountdown: (seconds: number) => ipcRenderer.invoke("start-countdown", seconds),
|
||||
cancelCountdown: () => ipcRenderer.invoke("cancel-countdown"),
|
||||
onCountdownTick: (callback: (seconds: number) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, seconds: number) => callback(seconds);
|
||||
ipcRenderer.on("countdown-tick", listener);
|
||||
return () => ipcRenderer.removeListener("countdown-tick", listener);
|
||||
},
|
||||
});
|
||||
|
||||
+280
-195
@@ -1,247 +1,332 @@
|
||||
import { BrowserWindow, ipcMain } from 'electron'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { app, BrowserWindow, ipcMain } from "electron";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const nodeRequire = createRequire(import.meta.url);
|
||||
|
||||
const APP_ROOT = path.join(__dirname, '..')
|
||||
const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
|
||||
const RENDERER_DIST = path.join(APP_ROOT, 'dist')
|
||||
const WINDOW_ICON_PATH = path.join(process.env.VITE_PUBLIC || RENDERER_DIST, 'app-icons', 'recordly-512.png')
|
||||
const APP_ROOT = path.join(__dirname, "..");
|
||||
const VITE_DEV_SERVER_URL = process.env["VITE_DEV_SERVER_URL"];
|
||||
const RENDERER_DIST = path.join(APP_ROOT, "dist");
|
||||
const WINDOW_ICON_PATH = path.join(
|
||||
process.env.VITE_PUBLIC || RENDERER_DIST,
|
||||
"app-icons",
|
||||
"recordly-512.png",
|
||||
);
|
||||
|
||||
let hudOverlayWindow: BrowserWindow | null = null;
|
||||
let hudOverlayHiddenFromCapture = true;
|
||||
let hudOverlayCaptureProtectionLoaded = false;
|
||||
let countdownWindow: BrowserWindow | null = null;
|
||||
|
||||
function getScreen() {
|
||||
return nodeRequire('electron').screen as typeof import('electron').screen
|
||||
const HUD_OVERLAY_SETTINGS_FILE = path.join(app.getPath("userData"), "hud-overlay-settings.json");
|
||||
|
||||
function isHudOverlayCaptureProtectionSupported(): boolean {
|
||||
return process.platform !== "linux";
|
||||
}
|
||||
|
||||
ipcMain.on('hud-overlay-hide', () => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.minimize();
|
||||
}
|
||||
function loadHudOverlayCaptureProtectionSetting(): boolean {
|
||||
if (hudOverlayCaptureProtectionLoaded) {
|
||||
return hudOverlayHiddenFromCapture;
|
||||
}
|
||||
|
||||
hudOverlayCaptureProtectionLoaded = true;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(HUD_OVERLAY_SETTINGS_FILE)) {
|
||||
return hudOverlayHiddenFromCapture;
|
||||
}
|
||||
|
||||
const raw = fs.readFileSync(HUD_OVERLAY_SETTINGS_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { hiddenFromCapture?: unknown };
|
||||
if (typeof parsed.hiddenFromCapture === "boolean") {
|
||||
hudOverlayHiddenFromCapture = parsed.hiddenFromCapture;
|
||||
}
|
||||
} catch {
|
||||
// Ignore settings read failures and fall back to defaults.
|
||||
}
|
||||
|
||||
return hudOverlayHiddenFromCapture;
|
||||
}
|
||||
|
||||
function persistHudOverlayCaptureProtectionSetting(enabled: boolean): void {
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
HUD_OVERLAY_SETTINGS_FILE,
|
||||
JSON.stringify({ hiddenFromCapture: enabled }, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
} catch {
|
||||
// Ignore settings write failures and keep runtime state working.
|
||||
}
|
||||
}
|
||||
|
||||
function getScreen() {
|
||||
return nodeRequire("electron").screen as typeof import("electron").screen;
|
||||
}
|
||||
|
||||
ipcMain.on("hud-overlay-hide", () => {
|
||||
if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) {
|
||||
hudOverlayWindow.minimize();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("get-hud-overlay-capture-protection", () => {
|
||||
const enabled = loadHudOverlayCaptureProtectionSetting();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
enabled,
|
||||
};
|
||||
});
|
||||
|
||||
ipcMain.handle("set-hud-overlay-capture-protection", (_event, enabled: boolean) => {
|
||||
loadHudOverlayCaptureProtectionSetting();
|
||||
hudOverlayHiddenFromCapture = Boolean(enabled);
|
||||
persistHudOverlayCaptureProtectionSetting(hudOverlayHiddenFromCapture);
|
||||
|
||||
if (
|
||||
isHudOverlayCaptureProtectionSupported() &&
|
||||
hudOverlayWindow &&
|
||||
!hudOverlayWindow.isDestroyed()
|
||||
) {
|
||||
hudOverlayWindow.setContentProtection(hudOverlayHiddenFromCapture);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
enabled: hudOverlayHiddenFromCapture,
|
||||
};
|
||||
});
|
||||
|
||||
export function createHudOverlayWindow(): BrowserWindow {
|
||||
const primaryDisplay = getScreen().getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
loadHudOverlayCaptureProtectionSetting();
|
||||
|
||||
const primaryDisplay = getScreen().getPrimaryDisplay();
|
||||
const { workArea } = primaryDisplay;
|
||||
|
||||
const windowWidth = 660;
|
||||
const windowHeight = 170;
|
||||
const windowWidth = 660;
|
||||
const windowHeight = 170;
|
||||
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
const x = Math.floor(workArea.x + (workArea.width - windowWidth) / 2);
|
||||
const y = Math.floor(workArea.y + workArea.height - windowHeight - 5);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: 660,
|
||||
maxWidth: 660,
|
||||
minHeight: 170,
|
||||
maxHeight: 170,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
minWidth: windowWidth,
|
||||
maxWidth: windowWidth,
|
||||
minHeight: windowHeight,
|
||||
maxHeight: windowHeight,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isHudOverlayCaptureProtectionSupported()) {
|
||||
win.setContentProtection(hudOverlayHiddenFromCapture);
|
||||
}
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
hudOverlayWindow = win;
|
||||
hudOverlayWindow = win;
|
||||
|
||||
win.on('closed', () => {
|
||||
if (hudOverlayWindow === win) {
|
||||
hudOverlayWindow = null;
|
||||
}
|
||||
});
|
||||
win.on("closed", () => {
|
||||
if (hudOverlayWindow === win) {
|
||||
hudOverlayWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=hud-overlay");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "hud-overlay" },
|
||||
});
|
||||
}
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=hud-overlay')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'hud-overlay' }
|
||||
})
|
||||
}
|
||||
|
||||
return win
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createEditorWindow(): BrowserWindow {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isMac = process.platform === "darwin";
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...(process.platform !== 'darwin' && {
|
||||
icon: WINDOW_ICON_PATH,
|
||||
}),
|
||||
...(isMac && {
|
||||
titleBarStyle: 'hiddenInset',
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
}),
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: 'Recordly',
|
||||
show: false,
|
||||
backgroundColor: '#000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 800,
|
||||
minHeight: 600,
|
||||
...(process.platform !== "darwin" && {
|
||||
icon: WINDOW_ICON_PATH,
|
||||
}),
|
||||
...(isMac && {
|
||||
titleBarStyle: "hiddenInset",
|
||||
trafficLightPosition: { x: 12, y: 12 },
|
||||
}),
|
||||
transparent: false,
|
||||
resizable: true,
|
||||
alwaysOnTop: false,
|
||||
skipTaskbar: false,
|
||||
title: "Recordly",
|
||||
show: false,
|
||||
backgroundColor: "#000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
webSecurity: false,
|
||||
backgroundThrottling: false,
|
||||
},
|
||||
});
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
win.show()
|
||||
win.maximize()
|
||||
})
|
||||
win.once("ready-to-show", () => {
|
||||
win.show();
|
||||
win.maximize();
|
||||
});
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
win?.webContents.send('main-process-message', (new Date).toLocaleString())
|
||||
})
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
win?.webContents.send("main-process-message", new Date().toLocaleString());
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=editor')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'editor' }
|
||||
})
|
||||
}
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=editor");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "editor" },
|
||||
});
|
||||
}
|
||||
|
||||
return win
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createSourceSelectorWindow(): BrowserWindow {
|
||||
const { width, height } = getScreen().getPrimaryDisplay().workAreaSize
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
show: false,
|
||||
...(process.platform !== 'darwin' && {
|
||||
icon: WINDOW_ICON_PATH,
|
||||
}),
|
||||
backgroundColor: '#00000000',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
const { width, height } = getScreen().getPrimaryDisplay().workAreaSize;
|
||||
|
||||
win.webContents.on('did-finish-load', () => {
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show()
|
||||
}
|
||||
}, 100)
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
width: 620,
|
||||
height: 420,
|
||||
minHeight: 350,
|
||||
maxHeight: 500,
|
||||
x: Math.round((width - 620) / 2),
|
||||
y: Math.round((height - 420) / 2),
|
||||
frame: false,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
transparent: true,
|
||||
show: false,
|
||||
...(process.platform !== "darwin" && {
|
||||
icon: WINDOW_ICON_PATH,
|
||||
}),
|
||||
backgroundColor: "#00000000",
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=source-selector')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'source-selector' }
|
||||
})
|
||||
}
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
setTimeout(() => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return win
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=source-selector");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "source-selector" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function createCountdownWindow(): BrowserWindow {
|
||||
const primaryDisplay = getScreen().getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
const primaryDisplay = getScreen().getPrimaryDisplay();
|
||||
const { width, height } = primaryDisplay.workAreaSize;
|
||||
|
||||
const windowSize = 200;
|
||||
const x = Math.floor((width - windowSize) / 2);
|
||||
const y = Math.floor((height - windowSize) / 2);
|
||||
const windowSize = 200;
|
||||
const x = Math.floor((width - windowSize) / 2);
|
||||
const y = Math.floor((height - windowSize) / 2);
|
||||
|
||||
const win = new BrowserWindow({
|
||||
width: windowSize,
|
||||
height: windowSize,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.mjs'),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
})
|
||||
const win = new BrowserWindow({
|
||||
width: windowSize,
|
||||
height: windowSize,
|
||||
x: x,
|
||||
y: y,
|
||||
frame: false,
|
||||
transparent: true,
|
||||
resizable: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hasShadow: false,
|
||||
focusable: true,
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.mjs"),
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
},
|
||||
});
|
||||
|
||||
countdownWindow = win;
|
||||
countdownWindow = win;
|
||||
|
||||
// Show on all workspaces/spaces so it follows the user
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||
|
||||
win.on('closed', () => {
|
||||
if (countdownWindow === win) {
|
||||
countdownWindow = null;
|
||||
}
|
||||
});
|
||||
win.webContents.on("did-finish-load", () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show();
|
||||
}
|
||||
});
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + '?windowType=countdown')
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, 'index.html'), {
|
||||
query: { windowType: 'countdown' }
|
||||
})
|
||||
}
|
||||
win.on("closed", () => {
|
||||
if (countdownWindow === win) {
|
||||
countdownWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
return win
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
win.loadURL(VITE_DEV_SERVER_URL + "?windowType=countdown");
|
||||
} else {
|
||||
win.loadFile(path.join(RENDERER_DIST, "index.html"), {
|
||||
query: { windowType: "countdown" },
|
||||
});
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
export function getCountdownWindow(): BrowserWindow | null {
|
||||
return countdownWindow;
|
||||
return countdownWindow;
|
||||
}
|
||||
|
||||
export function closeCountdownWindow(): void {
|
||||
if (countdownWindow && !countdownWindow.isDestroyed()) {
|
||||
countdownWindow.close();
|
||||
countdownWindow = null;
|
||||
}
|
||||
if (countdownWindow && !countdownWindow.isDestroyed()) {
|
||||
countdownWindow.close();
|
||||
countdownWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+69
-66
@@ -1,66 +1,69 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { CountdownOverlay } from "./components/countdown/CountdownOverlay";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
import { useI18n } from "./contexts/I18nContext";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState('');
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get('windowType') || '';
|
||||
setWindowType(type);
|
||||
|
||||
if (type === 'hud-overlay' || type === 'source-selector' || type === 'countdown') {
|
||||
document.body.style.background = 'transparent';
|
||||
document.documentElement.style.background = 'transparent';
|
||||
document.getElementById('root')?.style.setProperty('background', 'transparent');
|
||||
}
|
||||
|
||||
// Load custom fonts on app initialization
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error('Failed to load custom fonts:', error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = windowType === 'editor'
|
||||
? t('app.editorTitle', 'Recordly Editor')
|
||||
: t('app.name', 'Recordly');
|
||||
}, [windowType, locale, t]);
|
||||
|
||||
switch (windowType) {
|
||||
case 'hud-overlay':
|
||||
return <LaunchWindow />;
|
||||
case 'source-selector':
|
||||
return <SourceSelector />;
|
||||
case 'countdown':
|
||||
return <CountdownOverlay />;
|
||||
case 'editor':
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-950 text-white">
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl">
|
||||
<img src="/app-icons/recordly-128.png" alt={t('app.name', 'Recordly')} className="h-12 w-12 rounded-xl" />
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t('app.name', 'Recordly')}</h1>
|
||||
<p className="text-sm text-white/65">{t('app.subtitle', 'Screen recording and editing')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { CountdownOverlay } from "./components/countdown/CountdownOverlay";
|
||||
import { LaunchWindow } from "./components/launch/LaunchWindow";
|
||||
import { SourceSelector } from "./components/launch/SourceSelector";
|
||||
import { ShortcutsConfigDialog } from "./components/video-editor/ShortcutsConfigDialog";
|
||||
import VideoEditor from "./components/video-editor/VideoEditor";
|
||||
import { useI18n } from "./contexts/I18nContext";
|
||||
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
|
||||
import { loadAllCustomFonts } from "./lib/customFonts";
|
||||
|
||||
export default function App() {
|
||||
const [windowType, setWindowType] = useState("");
|
||||
const { locale, t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const type = params.get("windowType") || "";
|
||||
setWindowType(type);
|
||||
|
||||
if (type === "hud-overlay" || type === "source-selector" || type === "countdown") {
|
||||
document.body.style.background = "transparent";
|
||||
document.documentElement.style.background = "transparent";
|
||||
document.getElementById("root")?.style.setProperty("background", "transparent");
|
||||
}
|
||||
|
||||
loadAllCustomFonts().catch((error) => {
|
||||
console.error("Failed to load custom fonts:", error);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
document.title =
|
||||
windowType === "editor" ? t("app.editorTitle", "Recordly Editor") : t("app.name", "Recordly");
|
||||
}, [windowType, locale, t]);
|
||||
|
||||
switch (windowType) {
|
||||
case "hud-overlay":
|
||||
return <LaunchWindow />;
|
||||
case "source-selector":
|
||||
return <SourceSelector />;
|
||||
case "countdown":
|
||||
return <CountdownOverlay />;
|
||||
case "editor":
|
||||
return (
|
||||
<ShortcutsProvider>
|
||||
<VideoEditor />
|
||||
<ShortcutsConfigDialog />
|
||||
</ShortcutsProvider>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-slate-950 text-white">
|
||||
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-white/5 px-6 py-5 shadow-2xl shadow-black/30 backdrop-blur-xl">
|
||||
<img
|
||||
src="/app-icons/recordly-128.png"
|
||||
alt={t("app.name", "Recordly")}
|
||||
className="h-12 w-12 rounded-xl"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">{t("app.name", "Recordly")}</h1>
|
||||
<p className="text-sm text-white/65">
|
||||
{t("app.subtitle", "Screen recording and editing")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,390 +1,503 @@
|
||||
import { Eye, EyeOff, Languages, Timer } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsRecordCircle } from "react-icons/bs";
|
||||
import { FaRegStopCircle } from "react-icons/fa";
|
||||
import { FaFolderOpen } from "react-icons/fa6";
|
||||
import { FiMinus, FiX } from "react-icons/fi";
|
||||
import { MdMic, MdMicOff, MdMonitor, MdVideoFile, MdVolumeOff, MdVolumeUp } from "react-icons/md";
|
||||
import { Languages, Timer } from "lucide-react";
|
||||
import { RxDragHandleDots2 } from "react-icons/rx";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import type { AppLocale } from "@/i18n/config";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import { useScopedT } from "../../contexts/I18nContext";
|
||||
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
|
||||
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
|
||||
import { useScreenRecorder } from "../../hooks/useScreenRecorder";
|
||||
import { useScopedT } from "../../contexts/I18nContext";
|
||||
import { Button } from "../ui/button";
|
||||
import { AudioLevelMeter } from "../ui/audio-level-meter";
|
||||
import { Button } from "../ui/button";
|
||||
import { ContentClamp } from "../ui/content-clamp";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
import { useI18n } from "@/contexts/I18nContext";
|
||||
import { SUPPORTED_LOCALES } from "@/i18n/config";
|
||||
import type { AppLocale } from "@/i18n/config";
|
||||
import styles from "./LaunchWindow.module.css";
|
||||
|
||||
export function LaunchWindow() {
|
||||
const { locale, setLocale } = useI18n();
|
||||
const t = useScopedT('launch');
|
||||
const { locale, setLocale } = useI18n();
|
||||
const t = useScopedT("launch");
|
||||
|
||||
const LOCALE_LABELS: Record<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
|
||||
const {
|
||||
recording,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
countdownDelay,
|
||||
setCountdownDelay,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } = useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
const LOCALE_LABELS: Record<string, string> = { en: "EN", es: "ES", "zh-CN": "中文" };
|
||||
const {
|
||||
recording,
|
||||
countdownActive,
|
||||
toggleRecording,
|
||||
microphoneEnabled,
|
||||
setMicrophoneEnabled,
|
||||
microphoneDeviceId,
|
||||
setMicrophoneDeviceId,
|
||||
systemAudioEnabled,
|
||||
setSystemAudioEnabled,
|
||||
countdownDelay,
|
||||
setCountdownDelay,
|
||||
} = useScreenRecorder();
|
||||
const [recordingStart, setRecordingStart] = useState<number | null>(null);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const showMicControls = microphoneEnabled && !recording;
|
||||
const { devices, selectedDeviceId, setSelectedDeviceId } =
|
||||
useMicrophoneDevices(microphoneEnabled);
|
||||
const { level } = useAudioLevelMeter({
|
||||
enabled: showMicControls,
|
||||
deviceId: microphoneDeviceId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && selectedDeviceId !== "default") {
|
||||
setMicrophoneDeviceId(selectedDeviceId);
|
||||
}
|
||||
}, [selectedDeviceId, setMicrophoneDeviceId]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (recording) {
|
||||
if (!recordingStart) setRecordingStart(Date.now());
|
||||
timer = setInterval(() => {
|
||||
if (recordingStart) {
|
||||
setElapsed(Math.floor((Date.now() - recordingStart) / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
setRecordingStart(null);
|
||||
setElapsed(0);
|
||||
if (timer) clearInterval(timer);
|
||||
}
|
||||
return () => {
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [recording, recordingStart]);
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60).toString().padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
const formatTime = (seconds: number) => {
|
||||
const m = Math.floor(seconds / 60)
|
||||
.toString()
|
||||
.padStart(2, "0");
|
||||
const s = (seconds % 60).toString().padStart(2, "0");
|
||||
return `${m}:${s}`;
|
||||
};
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [recordingsDirectory, setRecordingsDirectory] = useState<string | null>(null);
|
||||
const [hideHudFromCapture, setHideHudFromCapture] = useState(true);
|
||||
const [platform, setPlatform] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
const checkSelectedSource = async () => {
|
||||
if (window.electronAPI) {
|
||||
const source = await window.electronAPI.getSelectedSource();
|
||||
if (source) {
|
||||
setSelectedSource(source.name);
|
||||
setHasSelectedSource(true);
|
||||
} else {
|
||||
setSelectedSource("Screen");
|
||||
setHasSelectedSource(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void checkSelectedSource();
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
void checkSelectedSource();
|
||||
const interval = setInterval(checkSelectedSource, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const openSourceSelector = () => {
|
||||
window.electronAPI?.openSourceSelector();
|
||||
};
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
const loadPlatform = async () => {
|
||||
try {
|
||||
const nextPlatform = await window.electronAPI.getPlatform();
|
||||
if (!cancelled) {
|
||||
setPlatform(nextPlatform);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load platform:", error);
|
||||
}
|
||||
};
|
||||
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
void loadPlatform();
|
||||
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) {
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendHudOverlayHide = () => {
|
||||
window.electronAPI?.hudOverlayHide?.();
|
||||
};
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const sendHudOverlayClose = () => {
|
||||
window.electronAPI?.hudOverlayClose?.();
|
||||
};
|
||||
const loadHudCaptureProtection = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI.getHudOverlayCaptureProtection();
|
||||
if (!cancelled && result.success) {
|
||||
setHideHudFromCapture(result.enabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load HUD capture protection state:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
if (result.success && result.path) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
void loadHudCaptureProtection();
|
||||
|
||||
useEffect(() => {
|
||||
const loadRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.getRecordingsDirectory();
|
||||
if (result.success) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
void loadRecordingsDirectory();
|
||||
}, []);
|
||||
const openSourceSelector = () => {
|
||||
window.electronAPI?.openSourceSelector();
|
||||
};
|
||||
|
||||
const recordingsDirectoryName = recordingsDirectory
|
||||
? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory
|
||||
: "recordings";
|
||||
const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35";
|
||||
const openVideoFile = async () => {
|
||||
const result = await window.electronAPI.openVideoFilePicker();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
}
|
||||
};
|
||||
if (result.success && result.path) {
|
||||
await window.electronAPI.setCurrentVideoPath(result.path);
|
||||
await window.electronAPI.switchToEditor();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-end justify-center bg-transparent overflow-hidden">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
|
||||
>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(event) => {
|
||||
setSelectedDeviceId(event.target.value);
|
||||
setMicrophoneDeviceId(event.target.value);
|
||||
}}
|
||||
className={`max-w-[230px] rounded-full border border-white/15 bg-[#131722] px-3 py-1 text-xs text-slate-100 outline-none ${styles.micSelect}`}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24" />
|
||||
</div>
|
||||
)}
|
||||
const openProjectFile = async () => {
|
||||
const result = await window.electronAPI.loadProjectFile();
|
||||
if (result.canceled || !result.success) {
|
||||
return;
|
||||
}
|
||||
await window.electronAPI.switchToEditor();
|
||||
};
|
||||
|
||||
<div
|
||||
className={`w-full mx-auto flex items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
|
||||
backdropFilter: "blur(16px) saturate(140%)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(140%)",
|
||||
border: "1px solid rgba(80,80,120,0.25)",
|
||||
minHeight: 48,
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={16} className="text-white/35" />
|
||||
</div>
|
||||
const sendHudOverlayHide = () => {
|
||||
window.electronAPI?.hudOverlayHide?.();
|
||||
};
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`gap-1 text-white/80 bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white/80" />
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
const sendHudOverlayClose = () => {
|
||||
window.electronAPI?.hudOverlayClose?.();
|
||||
};
|
||||
|
||||
<div className={dividerClass} />
|
||||
const toggleHudCaptureProtection = async () => {
|
||||
const nextValue = !hideHudFromCapture;
|
||||
|
||||
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={systemAudioEnabled ? t('recording.disableSystemAudio') : t('recording.enableSystemAudio')}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{systemAudioEnabled ? <MdVolumeUp size={16} className="text-[#2563EB]" /> : <MdVolumeOff size={16} className="text-white/35" />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={microphoneEnabled ? t('recording.disableMicrophone') : t('recording.enableMicrophone')}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{microphoneEnabled ? <MdMic size={16} className="text-[#2563EB]" /> : <MdMicOff size={16} className="text-white/35" />}
|
||||
</Button>
|
||||
</div>
|
||||
setHideHudFromCapture(nextValue);
|
||||
|
||||
<div className={dividerClass} />
|
||||
try {
|
||||
const result = await window.electronAPI.setHudOverlayCaptureProtection(nextValue);
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
disabled={recording}
|
||||
title={t('recording.countdownDelay')}
|
||||
className={`gap-1 text-white/70 hover:bg-transparent px-1 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Timer size={14} />
|
||||
<span>{countdownDelay > 0 ? `${countdownDelay}s` : t('recording.noDelay')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="min-w-[80px] max-h-none overflow-visible bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{[0, 3, 5, 10].map((delay) => (
|
||||
<DropdownMenuItem
|
||||
key={delay}
|
||||
onSelect={() => setCountdownDelay(delay)}
|
||||
className={`text-xs cursor-pointer ${
|
||||
countdownDelay === delay ? "text-white font-medium" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{delay === 0 ? t('recording.noDelay') : `${delay}s`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
if (!result.success) {
|
||||
setHideHudFromCapture(!nextValue);
|
||||
return;
|
||||
}
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={countdownActive || (!hasSelectedSource && !recording)}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={14} className="text-red-400" />
|
||||
<span className="text-red-400 font-medium tabular-nums">{formatTime(elapsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BsRecordCircle size={14} className={hasSelectedSource ? "text-white/85" : "text-white/35"} />
|
||||
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>{t('recording.record')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
setHideHudFromCapture(result.enabled);
|
||||
} catch (error) {
|
||||
console.error("Failed to update HUD capture protection:", error);
|
||||
setHideHudFromCapture(!nextValue);
|
||||
}
|
||||
};
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={chooseRecordingsDirectory}
|
||||
disabled={recording}
|
||||
title={recordingsDirectory ? t('recording.recordingFolder', undefined, { path: recordingsDirectory }) : t('recording.chooseRecordingsFolder')}
|
||||
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<ContentClamp truncateLength={18}>{t('recording.folderPath', undefined, { name: recordingsDirectoryName })}</ContentClamp>
|
||||
</Button>
|
||||
const chooseRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.chooseRecordingsDirectory();
|
||||
if (result.canceled) {
|
||||
return;
|
||||
}
|
||||
if (result.success && result.path) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
title={t('recording.openVideoFile')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<MdVideoFile size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
title={t('recording.openProject')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FaFolderOpen size={14} />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
title="Language"
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="min-w-[90px] bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setLocale(code as AppLocale)}
|
||||
className={`text-xs cursor-pointer ${
|
||||
locale === code ? "text-white font-medium" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[code] ?? code}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayHide}
|
||||
title={t('recording.hideHud')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiMinus size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayClose}
|
||||
title={t('recording.closeApp')}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiX size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
const loadRecordingsDirectory = async () => {
|
||||
const result = await window.electronAPI.getRecordingsDirectory();
|
||||
if (result.success) {
|
||||
setRecordingsDirectory(result.path);
|
||||
}
|
||||
};
|
||||
|
||||
void loadRecordingsDirectory();
|
||||
}, []);
|
||||
|
||||
const recordingsDirectoryName = recordingsDirectory
|
||||
? recordingsDirectory.split(/[\\/]/).filter(Boolean).pop() || recordingsDirectory
|
||||
: "recordings";
|
||||
const dividerClass = "mx-1 h-5 w-px shrink-0 bg-white/35";
|
||||
const supportsHudCaptureProtection = platform !== "linux";
|
||||
|
||||
const toggleMicrophone = () => {
|
||||
if (!recording) {
|
||||
setMicrophoneEnabled(!microphoneEnabled);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full items-end justify-center overflow-hidden bg-transparent px-3 pb-3 pt-2">
|
||||
<div className={`flex flex-col items-center gap-2 mx-auto ${styles.electronDrag}`}>
|
||||
{showMicControls && (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-full border border-white/15 bg-[rgba(18,18,26,0.92)] px-3 py-2 shadow-xl backdrop-blur-xl ${styles.electronNoDrag}`}
|
||||
>
|
||||
<select
|
||||
value={microphoneDeviceId || selectedDeviceId}
|
||||
onChange={(event) => {
|
||||
setSelectedDeviceId(event.target.value);
|
||||
setMicrophoneDeviceId(event.target.value);
|
||||
}}
|
||||
className={`max-w-[230px] rounded-full border border-white/15 bg-[#131722] px-3 py-1 text-xs text-slate-100 outline-none ${styles.micSelect}`}
|
||||
>
|
||||
{devices.map((device) => (
|
||||
<option key={device.deviceId} value={device.deviceId}>
|
||||
{device.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<AudioLevelMeter level={level} className="w-24" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`mx-auto inline-flex max-w-full items-center gap-1.5 px-3 py-2 ${styles.electronDrag} ${styles.hudBar}`}
|
||||
style={{
|
||||
borderRadius: 9999,
|
||||
background: "linear-gradient(135deg, rgba(28,28,36,0.97) 0%, rgba(18,18,26,0.96) 100%)",
|
||||
backdropFilter: "blur(16px) saturate(140%)",
|
||||
WebkitBackdropFilter: "blur(16px) saturate(140%)",
|
||||
border: "1px solid rgba(80,80,120,0.25)",
|
||||
minHeight: 48,
|
||||
}}
|
||||
>
|
||||
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
|
||||
<RxDragHandleDots2 size={16} className="text-white/35" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className={`gap-1 text-white/80 bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
onClick={openSourceSelector}
|
||||
disabled={recording}
|
||||
title={selectedSource}
|
||||
>
|
||||
<MdMonitor size={14} className="text-white/80" />
|
||||
<ContentClamp truncateLength={6}>{selectedSource}</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<div className={`flex items-center gap-1 ${styles.electronNoDrag}`}>
|
||||
{supportsHudCaptureProtection && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => void toggleHudCaptureProtection()}
|
||||
title={
|
||||
hideHudFromCapture
|
||||
? t("recording.showHudInVideo")
|
||||
: t("recording.hideHudFromVideo")
|
||||
}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{hideHudFromCapture ? (
|
||||
<EyeOff size={16} className="text-white/35" />
|
||||
) : (
|
||||
<Eye size={16} className="text-[#2563EB]" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
|
||||
disabled={recording}
|
||||
title={
|
||||
systemAudioEnabled
|
||||
? t("recording.disableSystemAudio")
|
||||
: t("recording.enableSystemAudio")
|
||||
}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{systemAudioEnabled ? (
|
||||
<MdVolumeUp size={16} className="text-[#2563EB]" />
|
||||
) : (
|
||||
<MdVolumeOff size={16} className="text-white/35" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={toggleMicrophone}
|
||||
disabled={recording}
|
||||
title={
|
||||
microphoneEnabled
|
||||
? t("recording.disableMicrophone")
|
||||
: t("recording.enableMicrophone")
|
||||
}
|
||||
className="text-white/80 hover:bg-transparent"
|
||||
>
|
||||
{microphoneEnabled ? (
|
||||
<MdMic size={16} className="text-[#2563EB]" />
|
||||
) : (
|
||||
<MdMicOff size={16} className="text-white/35" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={dividerClass} />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
disabled={recording || countdownActive}
|
||||
title={t("recording.countdownDelay")}
|
||||
className={`gap-1 px-1 text-xs text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Timer size={14} />
|
||||
<span>{countdownDelay > 0 ? `${countdownDelay}s` : t("recording.noDelay")}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="center"
|
||||
className="min-w-[80px] max-h-none overflow-visible border-white/15 bg-[rgba(28,28,36,0.97)] text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{[0, 3, 5, 10].map((delay) => (
|
||||
<DropdownMenuItem
|
||||
key={delay}
|
||||
onSelect={() => setCountdownDelay(delay)}
|
||||
className={`cursor-pointer text-xs ${
|
||||
countdownDelay === delay ? "font-medium text-white" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{delay === 0 ? t("recording.noDelay") : `${delay}s`}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={hasSelectedSource ? toggleRecording : openSourceSelector}
|
||||
disabled={countdownActive || (!hasSelectedSource && !recording)}
|
||||
className={`gap-1 text-white bg-transparent hover:bg-transparent px-0 text-xs ${styles.electronNoDrag}`}
|
||||
>
|
||||
{recording ? (
|
||||
<>
|
||||
<FaRegStopCircle size={14} className="text-red-400" />
|
||||
<span className="text-red-400 font-medium tabular-nums">{formatTime(elapsed)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<BsRecordCircle
|
||||
size={14}
|
||||
className={hasSelectedSource ? "text-white/85" : "text-white/35"}
|
||||
/>
|
||||
<span className={hasSelectedSource ? "text-white/80" : "text-white/35"}>
|
||||
{t("recording.record")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={chooseRecordingsDirectory}
|
||||
disabled={recording}
|
||||
title={
|
||||
recordingsDirectory
|
||||
? t("recording.recordingFolder", undefined, { path: recordingsDirectory })
|
||||
: t("recording.chooseRecordingsFolder")
|
||||
}
|
||||
className={`text-white/75 hover:bg-transparent px-1 text-[11px] underline decoration-white/45 underline-offset-2 ${styles.electronNoDrag}`}
|
||||
>
|
||||
<ContentClamp truncateLength={18}>
|
||||
{t("recording.folderPath", undefined, { name: recordingsDirectoryName })}
|
||||
</ContentClamp>
|
||||
</Button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openVideoFile}
|
||||
disabled={recording}
|
||||
title={t("recording.openVideoFile")}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<MdVideoFile size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={openProjectFile}
|
||||
disabled={recording}
|
||||
title={t("recording.openProject")}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FaFolderOpen size={14} />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
title="Language"
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<Languages size={14} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
side="top"
|
||||
align="end"
|
||||
className="min-w-[90px] bg-[rgba(28,28,36,0.97)] border-white/15 text-white/90 backdrop-blur-xl"
|
||||
>
|
||||
{SUPPORTED_LOCALES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
key={code}
|
||||
onSelect={() => setLocale(code as AppLocale)}
|
||||
className={`text-xs cursor-pointer ${
|
||||
locale === code ? "text-white font-medium" : "text-white/60"
|
||||
}`}
|
||||
>
|
||||
{LOCALE_LABELS[code] ?? code}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className={dividerClass} />
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayHide}
|
||||
title={t("recording.hideHud")}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiMinus size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="icon"
|
||||
onClick={sendHudOverlayClose}
|
||||
title={t("recording.closeApp")}
|
||||
className={`text-white/70 hover:bg-transparent ${styles.electronNoDrag}`}
|
||||
>
|
||||
<FiX size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2201
-1970
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_EDITOR_PREFERENCES,
|
||||
EDITOR_PREFERENCES_STORAGE_KEY,
|
||||
loadEditorPreferences,
|
||||
normalizeEditorPreferences,
|
||||
saveEditorPreferences,
|
||||
} from "./editorPreferences";
|
||||
|
||||
function createStorageMock(initialValues: Record<string, string> = {}): Storage {
|
||||
const store = new Map(Object.entries(initialValues));
|
||||
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear() {
|
||||
store.clear();
|
||||
},
|
||||
getItem(key) {
|
||||
return store.get(key) ?? null;
|
||||
},
|
||||
key(index) {
|
||||
return Array.from(store.keys())[index] ?? null;
|
||||
},
|
||||
removeItem(key) {
|
||||
store.delete(key);
|
||||
},
|
||||
setItem(key, value) {
|
||||
store.set(key, value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("editorPreferences", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("normalizes invalid values back to safe defaults", () => {
|
||||
expect(
|
||||
normalizeEditorPreferences({
|
||||
wallpaper: 123,
|
||||
showCursor: "yes",
|
||||
cropRegion: { x: 2, width: -1 },
|
||||
aspectRatio: "bad-value",
|
||||
customAspectWidth: "0",
|
||||
customAspectHeight: "",
|
||||
customWallpapers: "not-an-array",
|
||||
}),
|
||||
).toEqual(DEFAULT_EDITOR_PREFERENCES);
|
||||
});
|
||||
|
||||
it("loads stored editor control preferences", () => {
|
||||
vi.stubGlobal(
|
||||
"localStorage",
|
||||
createStorageMock({
|
||||
[EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({
|
||||
wallpaper: "#123456",
|
||||
backgroundBlur: 3.5,
|
||||
showCursor: false,
|
||||
cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 },
|
||||
aspectRatio: "native",
|
||||
exportFormat: "gif",
|
||||
gifFrameRate: 30,
|
||||
gifLoop: false,
|
||||
customAspectWidth: "21",
|
||||
customAspectHeight: "9",
|
||||
customWallpapers: ["data:image/jpeg;base64,abc"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(loadEditorPreferences()).toEqual({
|
||||
wallpaper: "#123456",
|
||||
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
|
||||
backgroundBlur: 3.5,
|
||||
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
|
||||
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
|
||||
showCursor: false,
|
||||
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
|
||||
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
|
||||
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
|
||||
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
|
||||
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
|
||||
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
|
||||
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
|
||||
padding: DEFAULT_EDITOR_PREFERENCES.padding,
|
||||
cropRegion: { x: 0.1, y: 0.2, width: 0.7, height: 0.6 },
|
||||
aspectRatio: "native",
|
||||
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
|
||||
exportFormat: "gif",
|
||||
gifFrameRate: 30,
|
||||
gifLoop: false,
|
||||
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
|
||||
customAspectWidth: "21",
|
||||
customAspectHeight: "9",
|
||||
customWallpapers: ["data:image/jpeg;base64,abc"],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the last valid custom aspect inputs while typing", () => {
|
||||
const localStorage = createStorageMock({
|
||||
[EDITOR_PREFERENCES_STORAGE_KEY]: JSON.stringify({
|
||||
aspectRatio: "16:9",
|
||||
customAspectWidth: "21",
|
||||
customAspectHeight: "9",
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("localStorage", localStorage);
|
||||
|
||||
saveEditorPreferences({ customAspectWidth: "", customAspectHeight: "abc" });
|
||||
|
||||
expect(loadEditorPreferences()).toEqual({
|
||||
aspectRatio: "16:9",
|
||||
wallpaper: DEFAULT_EDITOR_PREFERENCES.wallpaper,
|
||||
shadowIntensity: DEFAULT_EDITOR_PREFERENCES.shadowIntensity,
|
||||
backgroundBlur: DEFAULT_EDITOR_PREFERENCES.backgroundBlur,
|
||||
zoomMotionBlur: DEFAULT_EDITOR_PREFERENCES.zoomMotionBlur,
|
||||
connectZooms: DEFAULT_EDITOR_PREFERENCES.connectZooms,
|
||||
showCursor: DEFAULT_EDITOR_PREFERENCES.showCursor,
|
||||
loopCursor: DEFAULT_EDITOR_PREFERENCES.loopCursor,
|
||||
cursorSize: DEFAULT_EDITOR_PREFERENCES.cursorSize,
|
||||
cursorSmoothing: DEFAULT_EDITOR_PREFERENCES.cursorSmoothing,
|
||||
cursorMotionBlur: DEFAULT_EDITOR_PREFERENCES.cursorMotionBlur,
|
||||
cursorClickBounce: DEFAULT_EDITOR_PREFERENCES.cursorClickBounce,
|
||||
cursorSway: DEFAULT_EDITOR_PREFERENCES.cursorSway,
|
||||
borderRadius: DEFAULT_EDITOR_PREFERENCES.borderRadius,
|
||||
padding: DEFAULT_EDITOR_PREFERENCES.padding,
|
||||
cropRegion: DEFAULT_EDITOR_PREFERENCES.cropRegion,
|
||||
exportQuality: DEFAULT_EDITOR_PREFERENCES.exportQuality,
|
||||
exportFormat: DEFAULT_EDITOR_PREFERENCES.exportFormat,
|
||||
gifFrameRate: DEFAULT_EDITOR_PREFERENCES.gifFrameRate,
|
||||
gifLoop: DEFAULT_EDITOR_PREFERENCES.gifLoop,
|
||||
gifSizePreset: DEFAULT_EDITOR_PREFERENCES.gifSizePreset,
|
||||
customAspectWidth: "21",
|
||||
customAspectHeight: "9",
|
||||
customWallpapers: DEFAULT_EDITOR_PREFERENCES.customWallpapers,
|
||||
});
|
||||
});
|
||||
|
||||
it("saves all editor controls with normalization", () => {
|
||||
const localStorage = createStorageMock();
|
||||
vi.stubGlobal("localStorage", localStorage);
|
||||
|
||||
saveEditorPreferences({
|
||||
wallpaper: "linear-gradient(to right, #000000, #ffffff)",
|
||||
shadowIntensity: 0.4,
|
||||
backgroundBlur: 1.5,
|
||||
zoomMotionBlur: 0.75,
|
||||
connectZooms: false,
|
||||
showCursor: false,
|
||||
loopCursor: true,
|
||||
cursorSize: 3,
|
||||
cursorSmoothing: 1.25,
|
||||
cursorMotionBlur: 0.5,
|
||||
cursorClickBounce: 2.25,
|
||||
cursorSway: 1.5,
|
||||
borderRadius: 18,
|
||||
padding: 30,
|
||||
cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 },
|
||||
aspectRatio: "4:5",
|
||||
exportQuality: "source",
|
||||
exportFormat: "gif",
|
||||
gifFrameRate: 20,
|
||||
gifLoop: false,
|
||||
gifSizePreset: "large",
|
||||
customAspectWidth: "4",
|
||||
customAspectHeight: "5",
|
||||
customWallpapers: ["data:image/jpeg;base64,abc", "data:image/jpeg;base64,abc"],
|
||||
});
|
||||
|
||||
expect(loadEditorPreferences()).toEqual({
|
||||
wallpaper: "linear-gradient(to right, #000000, #ffffff)",
|
||||
shadowIntensity: 0.4,
|
||||
backgroundBlur: 1.5,
|
||||
zoomMotionBlur: 0.75,
|
||||
connectZooms: false,
|
||||
showCursor: false,
|
||||
loopCursor: true,
|
||||
cursorSize: 3,
|
||||
cursorSmoothing: 1.25,
|
||||
cursorMotionBlur: 0.5,
|
||||
cursorClickBounce: 2.25,
|
||||
cursorSway: 1.5,
|
||||
borderRadius: 18,
|
||||
padding: 30,
|
||||
cropRegion: { x: 0.12, y: 0.08, width: 0.7, height: 0.65 },
|
||||
aspectRatio: "4:5",
|
||||
exportQuality: "source",
|
||||
exportFormat: "gif",
|
||||
gifFrameRate: 20,
|
||||
gifLoop: false,
|
||||
gifSizePreset: "large",
|
||||
customAspectWidth: "4",
|
||||
customAspectHeight: "5",
|
||||
customWallpapers: ["data:image/jpeg;base64,abc"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { normalizeProjectEditor, type ProjectEditorState } from "./projectPersistence";
|
||||
|
||||
type PersistedEditorControls = Pick<
|
||||
ProjectEditorState,
|
||||
| "wallpaper"
|
||||
| "shadowIntensity"
|
||||
| "backgroundBlur"
|
||||
| "zoomMotionBlur"
|
||||
| "connectZooms"
|
||||
| "showCursor"
|
||||
| "loopCursor"
|
||||
| "cursorSize"
|
||||
| "cursorSmoothing"
|
||||
| "cursorMotionBlur"
|
||||
| "cursorClickBounce"
|
||||
| "cursorSway"
|
||||
| "borderRadius"
|
||||
| "padding"
|
||||
| "cropRegion"
|
||||
| "aspectRatio"
|
||||
| "exportQuality"
|
||||
| "exportFormat"
|
||||
| "gifFrameRate"
|
||||
| "gifLoop"
|
||||
| "gifSizePreset"
|
||||
>;
|
||||
|
||||
type PartialEditorControls = Partial<PersistedEditorControls>;
|
||||
|
||||
export interface EditorPreferences extends PersistedEditorControls {
|
||||
customAspectWidth: string;
|
||||
customAspectHeight: string;
|
||||
customWallpapers: string[];
|
||||
}
|
||||
|
||||
export const EDITOR_PREFERENCES_STORAGE_KEY = "recordly.editor.preferences";
|
||||
|
||||
const DEFAULT_EDITOR_CONTROLS = normalizeProjectEditor({});
|
||||
|
||||
export const DEFAULT_EDITOR_PREFERENCES: EditorPreferences = {
|
||||
wallpaper: DEFAULT_EDITOR_CONTROLS.wallpaper,
|
||||
shadowIntensity: DEFAULT_EDITOR_CONTROLS.shadowIntensity,
|
||||
backgroundBlur: DEFAULT_EDITOR_CONTROLS.backgroundBlur,
|
||||
zoomMotionBlur: DEFAULT_EDITOR_CONTROLS.zoomMotionBlur,
|
||||
connectZooms: DEFAULT_EDITOR_CONTROLS.connectZooms,
|
||||
showCursor: DEFAULT_EDITOR_CONTROLS.showCursor,
|
||||
loopCursor: DEFAULT_EDITOR_CONTROLS.loopCursor,
|
||||
cursorSize: DEFAULT_EDITOR_CONTROLS.cursorSize,
|
||||
cursorSmoothing: DEFAULT_EDITOR_CONTROLS.cursorSmoothing,
|
||||
cursorMotionBlur: DEFAULT_EDITOR_CONTROLS.cursorMotionBlur,
|
||||
cursorClickBounce: DEFAULT_EDITOR_CONTROLS.cursorClickBounce,
|
||||
cursorSway: DEFAULT_EDITOR_CONTROLS.cursorSway,
|
||||
borderRadius: DEFAULT_EDITOR_CONTROLS.borderRadius,
|
||||
padding: DEFAULT_EDITOR_CONTROLS.padding,
|
||||
cropRegion: DEFAULT_EDITOR_CONTROLS.cropRegion,
|
||||
aspectRatio: DEFAULT_EDITOR_CONTROLS.aspectRatio,
|
||||
exportQuality: DEFAULT_EDITOR_CONTROLS.exportQuality,
|
||||
exportFormat: DEFAULT_EDITOR_CONTROLS.exportFormat,
|
||||
gifFrameRate: DEFAULT_EDITOR_CONTROLS.gifFrameRate,
|
||||
gifLoop: DEFAULT_EDITOR_CONTROLS.gifLoop,
|
||||
gifSizePreset: DEFAULT_EDITOR_CONTROLS.gifSizePreset,
|
||||
customAspectWidth: "16",
|
||||
customAspectHeight: "9",
|
||||
customWallpapers: [],
|
||||
};
|
||||
|
||||
function normalizePositiveIntegerString(value: unknown, fallback: string): string {
|
||||
if (typeof value !== "string" || value.trim().length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return String(parsed);
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function normalizeCropRegion(
|
||||
value: unknown,
|
||||
fallback: EditorPreferences["cropRegion"],
|
||||
): EditorPreferences["cropRegion"] {
|
||||
if (!value || typeof value !== "object") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const raw = value as Partial<EditorPreferences["cropRegion"]>;
|
||||
const x = isFiniteNumber(raw.x) && raw.x >= 0 && raw.x < 1 ? raw.x : fallback.x;
|
||||
const y = isFiniteNumber(raw.y) && raw.y >= 0 && raw.y < 1 ? raw.y : fallback.y;
|
||||
const maxWidth = 1 - x;
|
||||
const maxHeight = 1 - y;
|
||||
const fallbackWidth = clamp(fallback.width, 0.01, maxWidth);
|
||||
const fallbackHeight = clamp(fallback.height, 0.01, maxHeight);
|
||||
const width =
|
||||
isFiniteNumber(raw.width) && raw.width >= 0.01 && raw.width <= maxWidth
|
||||
? raw.width
|
||||
: fallbackWidth;
|
||||
const height =
|
||||
isFiniteNumber(raw.height) && raw.height >= 0.01 && raw.height <= maxHeight
|
||||
? raw.height
|
||||
: fallbackHeight;
|
||||
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
function normalizeCustomWallpapers(value: unknown, fallback: string[]): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return Array.from(
|
||||
new Set(value.filter((item): item is string => typeof item === "string" && item.length > 0)),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeEditorControls(
|
||||
raw: Partial<EditorPreferences>,
|
||||
fallback: EditorPreferences,
|
||||
): PersistedEditorControls {
|
||||
const candidate: PartialEditorControls = {
|
||||
wallpaper: raw.wallpaper ?? fallback.wallpaper,
|
||||
shadowIntensity: raw.shadowIntensity ?? fallback.shadowIntensity,
|
||||
backgroundBlur: raw.backgroundBlur ?? fallback.backgroundBlur,
|
||||
zoomMotionBlur: raw.zoomMotionBlur ?? fallback.zoomMotionBlur,
|
||||
connectZooms: raw.connectZooms ?? fallback.connectZooms,
|
||||
showCursor: raw.showCursor ?? fallback.showCursor,
|
||||
loopCursor: raw.loopCursor ?? fallback.loopCursor,
|
||||
cursorSize: raw.cursorSize ?? fallback.cursorSize,
|
||||
cursorSmoothing: raw.cursorSmoothing ?? fallback.cursorSmoothing,
|
||||
cursorMotionBlur: raw.cursorMotionBlur ?? fallback.cursorMotionBlur,
|
||||
cursorClickBounce: raw.cursorClickBounce ?? fallback.cursorClickBounce,
|
||||
cursorSway: raw.cursorSway ?? fallback.cursorSway,
|
||||
borderRadius: raw.borderRadius ?? fallback.borderRadius,
|
||||
padding: raw.padding ?? fallback.padding,
|
||||
cropRegion: normalizeCropRegion(raw.cropRegion, fallback.cropRegion),
|
||||
aspectRatio: raw.aspectRatio ?? fallback.aspectRatio,
|
||||
exportQuality: raw.exportQuality ?? fallback.exportQuality,
|
||||
exportFormat: raw.exportFormat ?? fallback.exportFormat,
|
||||
gifFrameRate: raw.gifFrameRate ?? fallback.gifFrameRate,
|
||||
gifLoop: raw.gifLoop ?? fallback.gifLoop,
|
||||
gifSizePreset: raw.gifSizePreset ?? fallback.gifSizePreset,
|
||||
};
|
||||
|
||||
const normalized = normalizeProjectEditor(candidate);
|
||||
|
||||
return {
|
||||
wallpaper: normalized.wallpaper,
|
||||
shadowIntensity: normalized.shadowIntensity,
|
||||
backgroundBlur: normalized.backgroundBlur,
|
||||
zoomMotionBlur: normalized.zoomMotionBlur,
|
||||
connectZooms: normalized.connectZooms,
|
||||
showCursor: normalized.showCursor,
|
||||
loopCursor: normalized.loopCursor,
|
||||
cursorSize: normalized.cursorSize,
|
||||
cursorSmoothing: normalized.cursorSmoothing,
|
||||
cursorMotionBlur: normalized.cursorMotionBlur,
|
||||
cursorClickBounce: normalized.cursorClickBounce,
|
||||
cursorSway: normalized.cursorSway,
|
||||
borderRadius: normalized.borderRadius,
|
||||
padding: normalized.padding,
|
||||
cropRegion: normalized.cropRegion,
|
||||
aspectRatio: normalized.aspectRatio,
|
||||
exportQuality: normalized.exportQuality,
|
||||
exportFormat: normalized.exportFormat,
|
||||
gifFrameRate: normalized.gifFrameRate,
|
||||
gifLoop: normalized.gifLoop,
|
||||
gifSizePreset: normalized.gifSizePreset,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeEditorPreferences(
|
||||
candidate: unknown,
|
||||
fallback: EditorPreferences = DEFAULT_EDITOR_PREFERENCES,
|
||||
): EditorPreferences {
|
||||
const raw =
|
||||
candidate && typeof candidate === "object" ? (candidate as Partial<EditorPreferences>) : {};
|
||||
|
||||
return {
|
||||
...normalizeEditorControls(raw, fallback),
|
||||
customAspectWidth: normalizePositiveIntegerString(
|
||||
raw.customAspectWidth,
|
||||
fallback.customAspectWidth,
|
||||
),
|
||||
customAspectHeight: normalizePositiveIntegerString(
|
||||
raw.customAspectHeight,
|
||||
fallback.customAspectHeight,
|
||||
),
|
||||
customWallpapers: normalizeCustomWallpapers(raw.customWallpapers, fallback.customWallpapers),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadEditorPreferences(): EditorPreferences {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return DEFAULT_EDITOR_PREFERENCES;
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = globalThis.localStorage.getItem(EDITOR_PREFERENCES_STORAGE_KEY);
|
||||
if (!stored) {
|
||||
return DEFAULT_EDITOR_PREFERENCES;
|
||||
}
|
||||
|
||||
return normalizeEditorPreferences(JSON.parse(stored));
|
||||
} catch {
|
||||
return DEFAULT_EDITOR_PREFERENCES;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveEditorPreferences(preferences: Partial<EditorPreferences>): void {
|
||||
if (typeof globalThis.localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const current = loadEditorPreferences();
|
||||
const merged = normalizeEditorPreferences({ ...current, ...preferences }, current);
|
||||
globalThis.localStorage.setItem(EDITOR_PREFERENCES_STORAGE_KEY, JSON.stringify(merged));
|
||||
} catch {
|
||||
// Ignore storage failures so editor controls still work.
|
||||
}
|
||||
}
|
||||
@@ -1,391 +1,426 @@
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
|
||||
import { WALLPAPER_PATHS } from "@/lib/wallpapers";
|
||||
import { ASPECT_RATIOS, type AspectRatio, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import {
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_MOTION_BLUR,
|
||||
type AnnotationRegion,
|
||||
type CropRegion,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type AudioRegion,
|
||||
type ZoomRegion,
|
||||
type AnnotationRegion,
|
||||
type AudioRegion,
|
||||
type CropRegion,
|
||||
DEFAULT_ANNOTATION_POSITION,
|
||||
DEFAULT_ANNOTATION_SIZE,
|
||||
DEFAULT_ANNOTATION_STYLE,
|
||||
DEFAULT_CROP_REGION,
|
||||
DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
DEFAULT_CURSOR_MOTION_BLUR,
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_CURSOR_SWAY,
|
||||
DEFAULT_FIGURE_DATA,
|
||||
DEFAULT_PLAYBACK_SPEED,
|
||||
DEFAULT_ZOOM_DEPTH,
|
||||
DEFAULT_ZOOM_MOTION_BLUR,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
|
||||
export const PROJECT_VERSION = 1;
|
||||
|
||||
export interface ProjectEditorState {
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur: number;
|
||||
connectZooms: boolean;
|
||||
showCursor: boolean;
|
||||
loopCursor: boolean;
|
||||
cursorSize: number;
|
||||
cursorSmoothing: number;
|
||||
cursorMotionBlur: number;
|
||||
cursorClickBounce: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
cropRegion: CropRegion;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
audioRegions: AudioRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
wallpaper: string;
|
||||
shadowIntensity: number;
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur: number;
|
||||
connectZooms: boolean;
|
||||
showCursor: boolean;
|
||||
loopCursor: boolean;
|
||||
cursorSize: number;
|
||||
cursorSmoothing: number;
|
||||
cursorMotionBlur: number;
|
||||
cursorClickBounce: number;
|
||||
cursorSway: number;
|
||||
borderRadius: number;
|
||||
padding: number;
|
||||
cropRegion: CropRegion;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions: TrimRegion[];
|
||||
speedRegions: SpeedRegion[];
|
||||
annotationRegions: AnnotationRegion[];
|
||||
audioRegions: AudioRegion[];
|
||||
aspectRatio: AspectRatio;
|
||||
exportQuality: ExportQuality;
|
||||
exportFormat: ExportFormat;
|
||||
gifFrameRate: GifFrameRate;
|
||||
gifLoop: boolean;
|
||||
gifSizePreset: GifSizePreset;
|
||||
}
|
||||
|
||||
export interface EditorProjectData {
|
||||
version: number;
|
||||
videoPath: string;
|
||||
editor: ProjectEditorState;
|
||||
version: number;
|
||||
videoPath: string;
|
||||
editor: ProjectEditorState;
|
||||
}
|
||||
|
||||
function isFiniteNumber(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
return typeof value === "number" && Number.isFinite(value);
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function isFileUrl(value: string): boolean {
|
||||
return /^file:\/\//i.test(value);
|
||||
return /^file:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) return "";
|
||||
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join("/");
|
||||
return pathname
|
||||
.split("/")
|
||||
.map((segment, index) => {
|
||||
if (!segment) return "";
|
||||
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
|
||||
return segment;
|
||||
}
|
||||
return encodeURIComponent(segment);
|
||||
})
|
||||
.join("/");
|
||||
}
|
||||
|
||||
export function toFileUrl(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
|
||||
// Windows drive path: C:/Users/...
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
||||
}
|
||||
// Windows drive path: C:/Users/...
|
||||
if (/^[a-zA-Z]:\//.test(normalized)) {
|
||||
return `file://${encodePathSegments(`/${normalized}`, true)}`;
|
||||
}
|
||||
|
||||
// UNC path: //server/share/...
|
||||
if (normalized.startsWith("//")) {
|
||||
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
||||
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
||||
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
||||
}
|
||||
// UNC path: //server/share/...
|
||||
if (normalized.startsWith("//")) {
|
||||
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
|
||||
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
|
||||
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
|
||||
}
|
||||
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
|
||||
return `file://${encodePathSegments(absolutePath)}`;
|
||||
}
|
||||
|
||||
export function fromFileUrl(fileUrl: string): string {
|
||||
const value = fileUrl.trim();
|
||||
if (!isFileUrl(value)) {
|
||||
return fileUrl;
|
||||
}
|
||||
const value = fileUrl.trim();
|
||||
if (!isFileUrl(value)) {
|
||||
return fileUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
try {
|
||||
const url = new URL(value);
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
|
||||
if (url.host && url.host !== "localhost") {
|
||||
const uncPath = `//${url.host}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;
|
||||
return uncPath.replace(/\//g, "\\");
|
||||
}
|
||||
if (url.host && url.host !== "localhost") {
|
||||
const uncPath = `//${url.host}${pathname.startsWith("/") ? pathname : `/${pathname}`}`;
|
||||
return uncPath.replace(/\//g, "\\");
|
||||
}
|
||||
|
||||
if (/^\/[A-Za-z]:/.test(pathname)) {
|
||||
return pathname.slice(1);
|
||||
}
|
||||
if (/^\/[A-Za-z]:/.test(pathname)) {
|
||||
return pathname.slice(1);
|
||||
}
|
||||
|
||||
return pathname;
|
||||
} catch {
|
||||
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
||||
let fallbackPath = rawFallbackPath;
|
||||
try {
|
||||
fallbackPath = decodeURIComponent(rawFallbackPath);
|
||||
} catch {
|
||||
// Keep raw best-effort path if percent decoding fails.
|
||||
}
|
||||
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
||||
}
|
||||
return pathname;
|
||||
} catch {
|
||||
const rawFallbackPath = value.replace(/^file:\/\//i, "");
|
||||
let fallbackPath = rawFallbackPath;
|
||||
try {
|
||||
fallbackPath = decodeURIComponent(rawFallbackPath);
|
||||
} catch {
|
||||
// Keep raw best-effort path if percent decoding fails.
|
||||
}
|
||||
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveNextId(prefix: string, ids: string[]): number {
|
||||
const max = ids.reduce((acc, id) => {
|
||||
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
||||
if (!match) return acc;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? Math.max(acc, value) : acc;
|
||||
}, 0);
|
||||
return max + 1;
|
||||
const max = ids.reduce((acc, id) => {
|
||||
const match = id.match(new RegExp(`^${prefix}-(\\d+)$`));
|
||||
if (!match) return acc;
|
||||
const value = Number(match[1]);
|
||||
return Number.isFinite(value) ? Math.max(acc, value) : acc;
|
||||
}, 0);
|
||||
return max + 1;
|
||||
}
|
||||
|
||||
export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
const project = candidate as Partial<EditorProjectData>;
|
||||
if (typeof project.version !== "number") return false;
|
||||
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
|
||||
if (!project.editor || typeof project.editor !== "object") return false;
|
||||
return true;
|
||||
if (!candidate || typeof candidate !== "object") return false;
|
||||
const project = candidate as Partial<EditorProjectData>;
|
||||
if (typeof project.version !== "number") return false;
|
||||
if (typeof project.videoPath !== "string" || !project.videoPath) return false;
|
||||
if (!project.editor || typeof project.editor !== "object") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): ProjectEditorState {
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>).motionBlurEnabled;
|
||||
const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur;
|
||||
const normalizedZoomMotionBlur = isFiniteNumber((editor as Partial<ProjectEditorState>).zoomMotionBlur)
|
||||
? clamp((editor as Partial<ProjectEditorState>).zoomMotionBlur as number, 0, 2)
|
||||
: legacyMotionBlurEnabled
|
||||
? 0.35
|
||||
: DEFAULT_ZOOM_MOTION_BLUR;
|
||||
const normalizedBackgroundBlur = isFiniteNumber((editor as Partial<ProjectEditorState>).backgroundBlur)
|
||||
? clamp((editor as Partial<ProjectEditorState>).backgroundBlur as number, 0, 8)
|
||||
: legacyShowBlur
|
||||
? 2
|
||||
: 0;
|
||||
const validAspectRatios = new Set<AspectRatio>(ASPECT_RATIOS);
|
||||
const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>)
|
||||
.motionBlurEnabled;
|
||||
const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur;
|
||||
const normalizedZoomMotionBlur = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).zoomMotionBlur,
|
||||
)
|
||||
? clamp((editor as Partial<ProjectEditorState>).zoomMotionBlur as number, 0, 2)
|
||||
: legacyMotionBlurEnabled
|
||||
? 0.35
|
||||
: DEFAULT_ZOOM_MOTION_BLUR;
|
||||
const normalizedBackgroundBlur = isFiniteNumber(
|
||||
(editor as Partial<ProjectEditorState>).backgroundBlur,
|
||||
)
|
||||
? clamp((editor as Partial<ProjectEditorState>).backgroundBlur as number, 0, 8)
|
||||
: legacyShowBlur
|
||||
? 2
|
||||
: 0;
|
||||
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
|
||||
? editor.zoomRegions
|
||||
.filter((region): region is ZoomRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
|
||||
focus: {
|
||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
depth: [1, 2, 3, 4, 5, 6].includes(region.depth) ? region.depth : DEFAULT_ZOOM_DEPTH,
|
||||
focus: {
|
||||
cx: clamp(isFiniteNumber(region.focus?.cx) ? region.focus.cx : 0.5, 0, 1),
|
||||
cy: clamp(isFiniteNumber(region.focus?.cy) ? region.focus.cy : 0.5, 0, 1),
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
|
||||
? editor.trimRegions
|
||||
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const normalizedTrimRegions: TrimRegion[] = Array.isArray(editor.trimRegions)
|
||||
? editor.trimRegions
|
||||
.filter((region): region is TrimRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
|
||||
? editor.speedRegions
|
||||
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const normalizedSpeedRegions: SpeedRegion[] = Array.isArray(editor.speedRegions)
|
||||
? editor.speedRegions
|
||||
.filter((region): region is SpeedRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
const speed =
|
||||
region.speed === 0.25 ||
|
||||
region.speed === 0.5 ||
|
||||
region.speed === 0.75 ||
|
||||
region.speed === 1.25 ||
|
||||
region.speed === 1.5 ||
|
||||
region.speed === 1.75 ||
|
||||
region.speed === 2
|
||||
? region.speed
|
||||
: DEFAULT_PLAYBACK_SPEED;
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
speed,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
speed,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
|
||||
? editor.annotationRegions
|
||||
.filter((region): region is AnnotationRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region, index) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const normalizedAnnotationRegions: AnnotationRegion[] = Array.isArray(editor.annotationRegions)
|
||||
? editor.annotationRegions
|
||||
.filter((region): region is AnnotationRegion =>
|
||||
Boolean(region && typeof region.id === "string"),
|
||||
)
|
||||
.map((region, index) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
position: {
|
||||
x: clamp(
|
||||
isFiniteNumber(region.position?.x) ? region.position.x : DEFAULT_ANNOTATION_POSITION.x,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
y: clamp(
|
||||
isFiniteNumber(region.position?.y) ? region.position.y : DEFAULT_ANNOTATION_POSITION.y,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
},
|
||||
size: {
|
||||
width: clamp(
|
||||
isFiniteNumber(region.size?.width) ? region.size.width : DEFAULT_ANNOTATION_SIZE.width,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
height: clamp(
|
||||
isFiniteNumber(region.size?.height) ? region.size.height : DEFAULT_ANNOTATION_SIZE.height,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
},
|
||||
style: {
|
||||
...DEFAULT_ANNOTATION_STYLE,
|
||||
...(region.style && typeof region.style === "object" ? region.style : {}),
|
||||
},
|
||||
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
||||
figureData: region.figureData
|
||||
? {
|
||||
...DEFAULT_FIGURE_DATA,
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
type: region.type === "image" || region.type === "figure" ? region.type : "text",
|
||||
content: typeof region.content === "string" ? region.content : "",
|
||||
textContent: typeof region.textContent === "string" ? region.textContent : undefined,
|
||||
imageContent: typeof region.imageContent === "string" ? region.imageContent : undefined,
|
||||
position: {
|
||||
x: clamp(
|
||||
isFiniteNumber(region.position?.x)
|
||||
? region.position.x
|
||||
: DEFAULT_ANNOTATION_POSITION.x,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
y: clamp(
|
||||
isFiniteNumber(region.position?.y)
|
||||
? region.position.y
|
||||
: DEFAULT_ANNOTATION_POSITION.y,
|
||||
0,
|
||||
100,
|
||||
),
|
||||
},
|
||||
size: {
|
||||
width: clamp(
|
||||
isFiniteNumber(region.size?.width)
|
||||
? region.size.width
|
||||
: DEFAULT_ANNOTATION_SIZE.width,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
height: clamp(
|
||||
isFiniteNumber(region.size?.height)
|
||||
? region.size.height
|
||||
: DEFAULT_ANNOTATION_SIZE.height,
|
||||
1,
|
||||
200,
|
||||
),
|
||||
},
|
||||
style: {
|
||||
...DEFAULT_ANNOTATION_STYLE,
|
||||
...(region.style && typeof region.style === "object" ? region.style : {}),
|
||||
},
|
||||
zIndex: isFiniteNumber(region.zIndex) ? region.zIndex : index + 1,
|
||||
figureData: region.figureData
|
||||
? {
|
||||
...DEFAULT_FIGURE_DATA,
|
||||
...region.figureData,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const normalizedAudioRegions: AudioRegion[] = Array.isArray((editor as Partial<ProjectEditorState>).audioRegions)
|
||||
? ((editor as Partial<ProjectEditorState>).audioRegions as AudioRegion[])
|
||||
.filter((region): region is AudioRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
const normalizedAudioRegions: AudioRegion[] = Array.isArray(
|
||||
(editor as Partial<ProjectEditorState>).audioRegions,
|
||||
)
|
||||
? ((editor as Partial<ProjectEditorState>).audioRegions as AudioRegion[])
|
||||
.filter((region): region is AudioRegion => Boolean(region && typeof region.id === "string"))
|
||||
.map((region) => {
|
||||
const rawStart = isFiniteNumber(region.startMs) ? Math.round(region.startMs) : 0;
|
||||
const rawEnd = isFiniteNumber(region.endMs) ? Math.round(region.endMs) : rawStart + 1000;
|
||||
const startMs = Math.max(0, Math.min(rawStart, rawEnd));
|
||||
const endMs = Math.max(startMs + 1, rawEnd);
|
||||
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
|
||||
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: region.id,
|
||||
startMs,
|
||||
endMs,
|
||||
audioPath: typeof region.audioPath === "string" ? region.audioPath : "",
|
||||
volume: isFiniteNumber(region.volume) ? clamp(region.volume, 0, 1) : 1,
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x) ? editor.cropRegion.x : DEFAULT_CROP_REGION.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y) ? editor.cropRegion.y : DEFAULT_CROP_REGION.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width) ? editor.cropRegion.width : DEFAULT_CROP_REGION.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
|
||||
? editor.cropRegion.x
|
||||
: DEFAULT_CROP_REGION.x;
|
||||
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
|
||||
? editor.cropRegion.y
|
||||
: DEFAULT_CROP_REGION.y;
|
||||
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
|
||||
? editor.cropRegion.width
|
||||
: DEFAULT_CROP_REGION.width;
|
||||
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
|
||||
? editor.cropRegion.height
|
||||
: DEFAULT_CROP_REGION.height;
|
||||
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
const cropX = clamp(rawCropX, 0, 1);
|
||||
const cropY = clamp(rawCropY, 0, 1);
|
||||
const cropWidth = clamp(rawCropWidth, 0.01, 1 - cropX);
|
||||
const cropHeight = clamp(rawCropHeight, 0.01, 1 - cropY);
|
||||
|
||||
return {
|
||||
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
|
||||
backgroundBlur: normalizedBackgroundBlur,
|
||||
zoomMotionBlur: normalizedZoomMotionBlur,
|
||||
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
|
||||
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
|
||||
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
|
||||
cursorSize: isFiniteNumber(editor.cursorSize) ? clamp(editor.cursorSize, 0.5, 10) : DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
|
||||
? clamp(editor.cursorSmoothing, 0, 2)
|
||||
: DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
|
||||
: DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5)
|
||||
: DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
},
|
||||
zoomRegions: normalizedZoomRegions,
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
audioRegions: normalizedAudioRegions,
|
||||
aspectRatio:
|
||||
typeof editor.aspectRatio === "string" &&
|
||||
(validAspectRatios.has(editor.aspectRatio as AspectRatio) || isCustomAspectRatio(editor.aspectRatio))
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9",
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" ||
|
||||
editor.exportQuality === "good" ||
|
||||
editor.exportQuality === "high" ||
|
||||
editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" || editor.gifSizePreset === "large" || editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
};
|
||||
return {
|
||||
wallpaper: typeof editor.wallpaper === "string" ? editor.wallpaper : WALLPAPER_PATHS[0],
|
||||
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0.67,
|
||||
backgroundBlur: normalizedBackgroundBlur,
|
||||
zoomMotionBlur: normalizedZoomMotionBlur,
|
||||
connectZooms: typeof editor.connectZooms === "boolean" ? editor.connectZooms : true,
|
||||
showCursor: typeof editor.showCursor === "boolean" ? editor.showCursor : true,
|
||||
loopCursor: typeof editor.loopCursor === "boolean" ? editor.loopCursor : false,
|
||||
cursorSize: isFiniteNumber(editor.cursorSize)
|
||||
? clamp(editor.cursorSize, 0.5, 10)
|
||||
: DEFAULT_CURSOR_SIZE,
|
||||
cursorSmoothing: isFiniteNumber(editor.cursorSmoothing)
|
||||
? clamp(editor.cursorSmoothing, 0, 2)
|
||||
: DEFAULT_CURSOR_SMOOTHING,
|
||||
cursorMotionBlur: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorMotionBlur)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorMotionBlur as number, 0, 2)
|
||||
: DEFAULT_CURSOR_MOTION_BLUR,
|
||||
cursorClickBounce: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorClickBounce)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorClickBounce as number, 0, 5)
|
||||
: DEFAULT_CURSOR_CLICK_BOUNCE,
|
||||
cursorSway: isFiniteNumber((editor as Partial<ProjectEditorState>).cursorSway)
|
||||
? clamp((editor as Partial<ProjectEditorState>).cursorSway as number, 0, 2)
|
||||
: DEFAULT_CURSOR_SWAY,
|
||||
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 12.5,
|
||||
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
|
||||
cropRegion: {
|
||||
x: cropX,
|
||||
y: cropY,
|
||||
width: cropWidth,
|
||||
height: cropHeight,
|
||||
},
|
||||
zoomRegions: normalizedZoomRegions,
|
||||
trimRegions: normalizedTrimRegions,
|
||||
speedRegions: normalizedSpeedRegions,
|
||||
annotationRegions: normalizedAnnotationRegions,
|
||||
audioRegions: normalizedAudioRegions,
|
||||
aspectRatio:
|
||||
typeof editor.aspectRatio === "string" &&
|
||||
(validAspectRatios.has(editor.aspectRatio as AspectRatio) ||
|
||||
isCustomAspectRatio(editor.aspectRatio))
|
||||
? (editor.aspectRatio as AspectRatio)
|
||||
: "16:9",
|
||||
exportQuality:
|
||||
editor.exportQuality === "medium" ||
|
||||
editor.exportQuality === "good" ||
|
||||
editor.exportQuality === "high" ||
|
||||
editor.exportQuality === "source"
|
||||
? editor.exportQuality
|
||||
: "good",
|
||||
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
|
||||
gifFrameRate:
|
||||
editor.gifFrameRate === 15 ||
|
||||
editor.gifFrameRate === 20 ||
|
||||
editor.gifFrameRate === 25 ||
|
||||
editor.gifFrameRate === 30
|
||||
? editor.gifFrameRate
|
||||
: 15,
|
||||
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
|
||||
gifSizePreset:
|
||||
editor.gifSizePreset === "medium" ||
|
||||
editor.gifSizePreset === "large" ||
|
||||
editor.gifSizePreset === "original"
|
||||
? editor.gifSizePreset
|
||||
: "medium",
|
||||
};
|
||||
}
|
||||
|
||||
export function createProjectData(videoPath: string, editor: ProjectEditorState): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
videoPath,
|
||||
editor,
|
||||
};
|
||||
export function createProjectData(
|
||||
videoPath: string,
|
||||
editor: ProjectEditorState,
|
||||
): EditorProjectData {
|
||||
return {
|
||||
version: PROJECT_VERSION,
|
||||
videoPath,
|
||||
editor,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useShortcuts } from "@/contexts/ShortcutsContext";
|
||||
import { matchesShortcut } from "@/lib/shortcuts";
|
||||
import { ASPECT_RATIOS, type AspectRatio, getAspectRatioLabel, isCustomAspectRatio } from "@/utils/aspectRatioUtils";
|
||||
import { formatShortcut } from "@/utils/platformUtils";
|
||||
import { loadEditorPreferences, saveEditorPreferences } from "../editorPreferences";
|
||||
import { TutorialHelp } from "../TutorialHelp";
|
||||
import TimelineWrapper from "./TimelineWrapper";
|
||||
import Row from "./Row";
|
||||
@@ -653,6 +654,7 @@ export default function TimelineEditor({
|
||||
aspectRatio,
|
||||
onAspectRatioChange,
|
||||
}: TimelineEditorProps) {
|
||||
const initialEditorPreferences = useMemo(() => loadEditorPreferences(), []);
|
||||
const totalMs = useMemo(() => Math.max(0, Math.round(videoDuration * 1000)), [videoDuration]);
|
||||
const currentTimeMs = useMemo(() => Math.round(currentTime * 1000), [currentTime]);
|
||||
const timelineScale = useMemo(() => calculateTimelineScale(videoDuration), [videoDuration]);
|
||||
@@ -664,8 +666,8 @@ export default function TimelineEditor({
|
||||
const [range, setRange] = useState<Range>(() => createInitialRange(totalMs));
|
||||
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
|
||||
const [selectedKeyframeId, setSelectedKeyframeId] = useState<string | null>(null);
|
||||
const [customAspectWidth, setCustomAspectWidth] = useState('16');
|
||||
const [customAspectHeight, setCustomAspectHeight] = useState('9');
|
||||
const [customAspectWidth, setCustomAspectWidth] = useState(initialEditorPreferences.customAspectWidth);
|
||||
const [customAspectHeight, setCustomAspectHeight] = useState(initialEditorPreferences.customAspectHeight);
|
||||
const [scrollLabels, setScrollLabels] = useState({
|
||||
pan: 'Shift + Ctrl + Scroll',
|
||||
zoom: 'Ctrl + Scroll'
|
||||
@@ -684,6 +686,13 @@ export default function TimelineEditor({
|
||||
}
|
||||
}, [aspectRatio]);
|
||||
|
||||
useEffect(() => {
|
||||
saveEditorPreferences({
|
||||
customAspectWidth,
|
||||
customAspectHeight,
|
||||
});
|
||||
}, [customAspectHeight, customAspectWidth]);
|
||||
|
||||
const applyCustomAspectRatio = useCallback(() => {
|
||||
const width = Number.parseInt(customAspectWidth, 10);
|
||||
const height = Number.parseInt(customAspectHeight, 10);
|
||||
|
||||
@@ -17,8 +17,23 @@ export interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup';
|
||||
cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed';
|
||||
interactionType?:
|
||||
| "move"
|
||||
| "click"
|
||||
| "double-click"
|
||||
| "right-click"
|
||||
| "middle-click"
|
||||
| "mouseup";
|
||||
cursorType?:
|
||||
| "arrow"
|
||||
| "text"
|
||||
| "pointer"
|
||||
| "crosshair"
|
||||
| "open-hand"
|
||||
| "closed-hand"
|
||||
| "resize-ew"
|
||||
| "resize-ns"
|
||||
| "not-allowed";
|
||||
}
|
||||
|
||||
export interface CursorVisualSettings {
|
||||
@@ -26,12 +41,14 @@ export interface CursorVisualSettings {
|
||||
smoothing: number;
|
||||
motionBlur: number;
|
||||
clickBounce: number;
|
||||
sway: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_SIZE = 3.0;
|
||||
export const DEFAULT_CURSOR_SMOOTHING = 0.67;
|
||||
export const DEFAULT_CURSOR_MOTION_BLUR = 0.35;
|
||||
export const DEFAULT_CURSOR_CLICK_BOUNCE = 2.5;
|
||||
export const DEFAULT_CURSOR_SWAY = 0;
|
||||
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
|
||||
|
||||
export interface TrimRegion {
|
||||
@@ -40,9 +57,17 @@ export interface TrimRegion {
|
||||
endMs: number;
|
||||
}
|
||||
|
||||
export type AnnotationType = 'text' | 'image' | 'figure';
|
||||
export type AnnotationType = "text" | "image" | "figure";
|
||||
|
||||
export type ArrowDirection = 'up' | 'down' | 'left' | 'right' | 'up-right' | 'up-left' | 'down-right' | 'down-left';
|
||||
export type ArrowDirection =
|
||||
| "up"
|
||||
| "down"
|
||||
| "left"
|
||||
| "right"
|
||||
| "up-right"
|
||||
| "up-left"
|
||||
| "down-right"
|
||||
| "down-left";
|
||||
|
||||
export interface FigureData {
|
||||
arrowDirection: ArrowDirection;
|
||||
@@ -65,18 +90,18 @@ export interface AnnotationTextStyle {
|
||||
backgroundColor: string;
|
||||
fontSize: number; // pixels
|
||||
fontFamily: string;
|
||||
fontWeight: 'normal' | 'bold';
|
||||
fontStyle: 'normal' | 'italic';
|
||||
textDecoration: 'none' | 'underline';
|
||||
textAlign: 'left' | 'center' | 'right';
|
||||
fontWeight: "normal" | "bold";
|
||||
fontStyle: "normal" | "italic";
|
||||
textDecoration: "none" | "underline";
|
||||
textAlign: "left" | "center" | "right";
|
||||
}
|
||||
|
||||
function getDefaultAnnotationFontFamily() {
|
||||
if (typeof navigator !== 'undefined' && /mac/i.test(navigator.platform)) {
|
||||
if (typeof navigator !== "undefined" && /mac/i.test(navigator.platform)) {
|
||||
return '"SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
}
|
||||
|
||||
return 'Inter, system-ui, sans-serif';
|
||||
return "Inter, system-ui, sans-serif";
|
||||
}
|
||||
|
||||
export interface AnnotationRegion {
|
||||
@@ -105,29 +130,27 @@ export const DEFAULT_ANNOTATION_SIZE: AnnotationSize = {
|
||||
};
|
||||
|
||||
export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
|
||||
color: '#ffffff',
|
||||
backgroundColor: 'transparent',
|
||||
color: "#ffffff",
|
||||
backgroundColor: "transparent",
|
||||
fontSize: 32,
|
||||
fontFamily: getDefaultAnnotationFontFamily(),
|
||||
fontWeight: 'bold',
|
||||
fontStyle: 'normal',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center',
|
||||
fontWeight: "bold",
|
||||
fontStyle: "normal",
|
||||
textDecoration: "none",
|
||||
textAlign: "center",
|
||||
};
|
||||
|
||||
export const DEFAULT_FIGURE_DATA: FigureData = {
|
||||
arrowDirection: 'right',
|
||||
color: '#2563EB',
|
||||
arrowDirection: "right",
|
||||
color: "#2563EB",
|
||||
strokeWidth: 4,
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface CropRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CROP_REGION: CropRegion = {
|
||||
@@ -177,7 +200,10 @@ export const ZOOM_DEPTH_SCALES: Record<ZoomDepth, number> = {
|
||||
|
||||
export const DEFAULT_ZOOM_DEPTH: ZoomDepth = 3;
|
||||
|
||||
export function clampFocusToDepth(focus: ZoomFocus, _depth: ZoomDepth): ZoomFocus {
|
||||
export function clampFocusToDepth(
|
||||
focus: ZoomFocus,
|
||||
_depth: ZoomDepth,
|
||||
): ZoomFocus {
|
||||
return {
|
||||
cx: clamp(focus.cx, 0, 1),
|
||||
cy: clamp(focus.cy, 0, 1),
|
||||
@@ -188,4 +214,3 @@ function clamp(value: number, min: number, max: number) {
|
||||
if (Number.isNaN(value)) return (min + max) / 2;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import { Assets, BlurFilter, Container, Graphics, Sprite, Texture } from 'pixi.js';
|
||||
import { MotionBlurFilter } from 'pixi-filters/motion-blur';
|
||||
import type { CursorTelemetryPoint } from '../types';
|
||||
import { createSpringState, getCursorSpringConfig, resetSpringState, stepSpringValue } from './motionSmoothing';
|
||||
import { uploadedCursorAssets, UPLOADED_CURSOR_SAMPLE_SIZE } from './uploadedCursorAssets';
|
||||
import {
|
||||
Assets,
|
||||
BlurFilter,
|
||||
Container,
|
||||
Graphics,
|
||||
Sprite,
|
||||
Texture,
|
||||
} from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type { CursorTelemetryPoint } from "../types";
|
||||
import {
|
||||
createSpringState,
|
||||
getCursorSpringConfig,
|
||||
resetSpringState,
|
||||
stepSpringValue,
|
||||
} from "./motionSmoothing";
|
||||
import { computeCursorSwayRotation } from "./cursorSway";
|
||||
import {
|
||||
uploadedCursorAssets,
|
||||
UPLOADED_CURSOR_SAMPLE_SIZE,
|
||||
} from "./uploadedCursorAssets";
|
||||
|
||||
type CursorAssetKey = NonNullable<CursorTelemetryPoint['cursorType']>;
|
||||
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
|
||||
|
||||
type LoadedCursorAsset = {
|
||||
texture: Texture;
|
||||
@@ -39,6 +55,8 @@ export interface CursorRenderConfig {
|
||||
motionBlur: number;
|
||||
/** Click bounce multiplier. */
|
||||
clickBounce: number;
|
||||
/** Cursor sway multiplier. */
|
||||
sway: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
|
||||
@@ -49,6 +67,7 @@ export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
|
||||
smoothingFactor: 0.18,
|
||||
motionBlur: 0,
|
||||
clickBounce: 1,
|
||||
sway: 0,
|
||||
};
|
||||
|
||||
const REFERENCE_WIDTH = 1920;
|
||||
@@ -57,7 +76,10 @@ const CLICK_ANIMATION_MS = 140;
|
||||
const CLICK_RING_FADE_MS = 240;
|
||||
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
|
||||
const CURSOR_TIME_DISCONTINUITY_MS = 100;
|
||||
const CURSOR_SVG_DROP_SHADOW_FILTER = 'drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))';
|
||||
const CURSOR_SWAY_SMOOTHING_MULTIPLIER = 0.7;
|
||||
const CURSOR_SWAY_SMOOTHING_OFFSET = 0.18;
|
||||
const CURSOR_SVG_DROP_SHADOW_FILTER =
|
||||
"drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))";
|
||||
const CURSOR_SHADOW_COLOR = 0x000000;
|
||||
const CURSOR_SHADOW_ALPHA = 0.35;
|
||||
const CURSOR_SHADOW_OFFSET_X = 0;
|
||||
@@ -68,22 +90,25 @@ const CURSOR_SHADOW_PADDING = 12;
|
||||
let cursorAssetsPromise: Promise<void> | null = null;
|
||||
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
|
||||
const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
|
||||
'arrow',
|
||||
'text',
|
||||
'pointer',
|
||||
'crosshair',
|
||||
'open-hand',
|
||||
'closed-hand',
|
||||
'resize-ew',
|
||||
'resize-ns',
|
||||
'not-allowed',
|
||||
"arrow",
|
||||
"text",
|
||||
"pointer",
|
||||
"crosshair",
|
||||
"open-hand",
|
||||
"closed-hand",
|
||||
"resize-ew",
|
||||
"resize-ns",
|
||||
"not-allowed",
|
||||
];
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`));
|
||||
image.onerror = () =>
|
||||
reject(
|
||||
new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`),
|
||||
);
|
||||
image.src = dataUrl;
|
||||
});
|
||||
}
|
||||
@@ -92,7 +117,10 @@ function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function getNormalizedAnchor(systemAsset: SystemCursorAsset | undefined, fallbackAnchor: { x: number; y: number }) {
|
||||
function getNormalizedAnchor(
|
||||
systemAsset: SystemCursorAsset | undefined,
|
||||
fallbackAnchor: { x: number; y: number },
|
||||
) {
|
||||
if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) {
|
||||
return fallbackAnchor;
|
||||
}
|
||||
@@ -120,17 +148,17 @@ async function rasterizeAndCropSvg(
|
||||
const img = await loadImage(url);
|
||||
|
||||
// Draw at full sample size
|
||||
const srcCanvas = document.createElement('canvas');
|
||||
const srcCanvas = document.createElement("canvas");
|
||||
srcCanvas.width = sampleSize;
|
||||
srcCanvas.height = sampleSize;
|
||||
const srcCtx = srcCanvas.getContext('2d')!;
|
||||
const srcCtx = srcCanvas.getContext("2d")!;
|
||||
srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize);
|
||||
|
||||
// Crop to trim bounds
|
||||
const dstCanvas = document.createElement('canvas');
|
||||
const dstCanvas = document.createElement("canvas");
|
||||
dstCanvas.width = trimWidth;
|
||||
dstCanvas.height = trimHeight;
|
||||
const dstCtx = dstCanvas.getContext('2d')!;
|
||||
const dstCtx = dstCanvas.getContext("2d")!;
|
||||
dstCtx.drawImage(
|
||||
srcCanvas,
|
||||
trimX,
|
||||
@@ -144,7 +172,7 @@ async function rasterizeAndCropSvg(
|
||||
);
|
||||
|
||||
return {
|
||||
dataUrl: dstCanvas.toDataURL('image/png'),
|
||||
dataUrl: dstCanvas.toDataURL("image/png"),
|
||||
width: dstCanvas.width,
|
||||
height: dstCanvas.height,
|
||||
};
|
||||
@@ -161,7 +189,7 @@ function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset {
|
||||
|
||||
function getAvailableCursorKeys(): CursorAssetKey[] {
|
||||
const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[];
|
||||
return loadedKeys.length > 0 ? loadedKeys : ['arrow'];
|
||||
return loadedKeys.length > 0 ? loadedKeys : ["arrow"];
|
||||
}
|
||||
|
||||
export async function preloadCursorAssets() {
|
||||
@@ -176,7 +204,10 @@ export async function preloadCursorAssets() {
|
||||
systemCursors = result.cursors;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[CursorRenderer] Failed to fetch system cursor assets:', error);
|
||||
console.warn(
|
||||
"[CursorRenderer] Failed to fetch system cursor assets:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
const entries = await Promise.all(
|
||||
@@ -220,31 +251,42 @@ export async function preloadCursorAssets() {
|
||||
const img = await loadImage(finalUrl);
|
||||
width = img.naturalWidth;
|
||||
height = img.naturalHeight;
|
||||
normalizedAnchor = getNormalizedAnchor(systemAsset, { x: 0, y: 0 });
|
||||
normalizedAnchor = getNormalizedAnchor(systemAsset, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
}
|
||||
|
||||
await Assets.load(finalUrl);
|
||||
const image = await loadImage(finalUrl);
|
||||
const texture = Texture.from(finalUrl);
|
||||
|
||||
return [key, {
|
||||
texture,
|
||||
image,
|
||||
aspectRatio: height > 0 ? width / height : 1,
|
||||
anchorX: normalizedAnchor.x,
|
||||
anchorY: normalizedAnchor.y,
|
||||
} satisfies LoadedCursorAsset] as const;
|
||||
return [
|
||||
key,
|
||||
{
|
||||
texture,
|
||||
image,
|
||||
aspectRatio: height > 0 ? width / height : 1,
|
||||
anchorX: normalizedAnchor.x,
|
||||
anchorY: normalizedAnchor.y,
|
||||
} satisfies LoadedCursorAsset,
|
||||
] as const;
|
||||
} catch (error) {
|
||||
console.warn(`[CursorRenderer] Failed to load cursor image for: ${key}`, error);
|
||||
console.warn(
|
||||
`[CursorRenderer] Failed to load cursor image for: ${key}`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
loadedCursorAssets = Object.fromEntries(entries.filter(Boolean).map((entry) => entry!)) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>;
|
||||
loadedCursorAssets = Object.fromEntries(
|
||||
entries.filter(Boolean).map((entry) => entry!),
|
||||
) as Partial<Record<CursorAssetKey, LoadedCursorAsset>>;
|
||||
|
||||
if (!loadedCursorAssets.arrow) {
|
||||
throw new Error('Failed to initialize the fallback arrow cursor asset');
|
||||
throw new Error("Failed to initialize the fallback arrow cursor asset");
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -267,7 +309,10 @@ export function interpolateCursorPosition(
|
||||
}
|
||||
|
||||
if (timeMs >= samples[samples.length - 1].timeMs) {
|
||||
return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy };
|
||||
return {
|
||||
cx: samples[samples.length - 1].cx,
|
||||
cy: samples[samples.length - 1].cy,
|
||||
};
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
@@ -310,17 +355,22 @@ function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
return samples[lo]?.timeMs <= timeMs ? samples[lo] : null;
|
||||
}
|
||||
|
||||
function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
function findLatestInteractionSample(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
) {
|
||||
for (let index = samples.length - 1; index >= 0; index -= 1) {
|
||||
const sample = samples[index];
|
||||
if (sample.timeMs > timeMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sample.interactionType === 'click'
|
||||
|| sample.interactionType === 'double-click'
|
||||
|| sample.interactionType === 'right-click'
|
||||
|| sample.interactionType === 'middle-click') {
|
||||
if (
|
||||
sample.interactionType === "click" ||
|
||||
sample.interactionType === "double-click" ||
|
||||
sample.interactionType === "right-click" ||
|
||||
sample.interactionType === "middle-click"
|
||||
) {
|
||||
return sample;
|
||||
}
|
||||
}
|
||||
@@ -328,7 +378,10 @@ function findLatestInteractionSample(samples: CursorTelemetryPoint[], timeMs: nu
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
function findLatestStableCursorType(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
) {
|
||||
// Binary search to find position at timeMs, then scan backwards
|
||||
let lo = 0;
|
||||
let hi = samples.length - 1;
|
||||
@@ -353,41 +406,69 @@ function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: num
|
||||
continue;
|
||||
}
|
||||
|
||||
if (sample.interactionType === 'click'
|
||||
|| sample.interactionType === 'double-click'
|
||||
|| sample.interactionType === 'right-click'
|
||||
|| sample.interactionType === 'middle-click') {
|
||||
if (
|
||||
sample.interactionType === "click" ||
|
||||
sample.interactionType === "double-click" ||
|
||||
sample.interactionType === "right-click" ||
|
||||
sample.interactionType === "middle-click"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return sample.cursorType;
|
||||
}
|
||||
|
||||
return findLatestSample(samples, timeMs)?.cursorType ?? 'arrow';
|
||||
return findLatestSample(samples, timeMs)?.cursorType ?? "arrow";
|
||||
}
|
||||
|
||||
function getCursorViewportScale(viewport: CursorViewportRect) {
|
||||
return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
|
||||
}
|
||||
|
||||
function getCursorSwaySpringConfig(smoothingFactor: number) {
|
||||
const baseConfig = getCursorSpringConfig(
|
||||
Math.min(
|
||||
2,
|
||||
Math.max(
|
||||
0.15,
|
||||
smoothingFactor * CURSOR_SWAY_SMOOTHING_MULTIPLIER +
|
||||
CURSOR_SWAY_SMOOTHING_OFFSET,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...baseConfig,
|
||||
damping: baseConfig.damping * 0.9,
|
||||
mass: Math.max(0.55, baseConfig.mass * 0.8),
|
||||
restDelta: 0.0005,
|
||||
restSpeed: 0.02,
|
||||
};
|
||||
}
|
||||
|
||||
function getCursorVisualState(samples: CursorTelemetryPoint[], timeMs: number) {
|
||||
const latestClick = findLatestInteractionSample(samples, timeMs);
|
||||
const interactionType = latestClick?.interactionType;
|
||||
const ageMs = latestClick ? Math.max(0, timeMs - latestClick.timeMs) : Number.POSITIVE_INFINITY;
|
||||
const isClickEvent = interactionType === 'click'
|
||||
|| interactionType === 'double-click'
|
||||
|| interactionType === 'right-click'
|
||||
|| interactionType === 'middle-click';
|
||||
const clickBounceProgress = latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS
|
||||
? 1 - ageMs / CLICK_ANIMATION_MS
|
||||
: 0;
|
||||
const ageMs = latestClick
|
||||
? Math.max(0, timeMs - latestClick.timeMs)
|
||||
: Number.POSITIVE_INFINITY;
|
||||
const isClickEvent =
|
||||
interactionType === "click" ||
|
||||
interactionType === "double-click" ||
|
||||
interactionType === "right-click" ||
|
||||
interactionType === "middle-click";
|
||||
const clickBounceProgress =
|
||||
latestClick && isClickEvent && ageMs <= CLICK_ANIMATION_MS
|
||||
? 1 - ageMs / CLICK_ANIMATION_MS
|
||||
: 0;
|
||||
|
||||
return {
|
||||
cursorType: findLatestStableCursorType(samples, timeMs),
|
||||
clickBounceProgress,
|
||||
clickProgress: latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
|
||||
? 1 - ageMs / CLICK_RING_FADE_MS
|
||||
: 0,
|
||||
clickProgress:
|
||||
latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
|
||||
? 1 - ageMs / CLICK_RING_FADE_MS
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -405,7 +486,9 @@ export class SmoothedCursorState {
|
||||
private xSpring = createSpringState(0.5);
|
||||
private ySpring = createSpringState(0.5);
|
||||
|
||||
constructor(config: Pick<CursorRenderConfig, 'smoothingFactor' | 'trailLength'>) {
|
||||
constructor(
|
||||
config: Pick<CursorRenderConfig, "smoothingFactor" | "trailLength">,
|
||||
) {
|
||||
this.smoothingFactor = config.smoothingFactor;
|
||||
this.trailLength = config.trailLength;
|
||||
}
|
||||
@@ -426,7 +509,10 @@ export class SmoothedCursorState {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) {
|
||||
if (
|
||||
this.smoothingFactor <= 0 ||
|
||||
(this.lastTimeMs !== null && timeMs < this.lastTimeMs)
|
||||
) {
|
||||
this.snapTo(targetX, targetY, timeMs);
|
||||
return;
|
||||
}
|
||||
@@ -436,7 +522,10 @@ export class SmoothedCursorState {
|
||||
this.trail.length = this.trailLength;
|
||||
}
|
||||
|
||||
const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs);
|
||||
const deltaMs =
|
||||
this.lastTimeMs === null
|
||||
? 1000 / 60
|
||||
: Math.max(1, timeMs - this.lastTimeMs);
|
||||
this.lastTimeMs = timeMs;
|
||||
|
||||
const springConfig = getCursorSpringConfig(this.smoothingFactor);
|
||||
@@ -471,7 +560,13 @@ export class SmoothedCursorState {
|
||||
}
|
||||
}
|
||||
|
||||
function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) {
|
||||
function drawClickRing(
|
||||
graphics: Graphics,
|
||||
px: number,
|
||||
py: number,
|
||||
h: number,
|
||||
progress: number,
|
||||
) {
|
||||
void graphics;
|
||||
void px;
|
||||
void py;
|
||||
@@ -490,13 +585,15 @@ export class PixiCursorOverlay {
|
||||
private config: CursorRenderConfig;
|
||||
private lastRenderedPoint: { px: number; py: number } | null = null;
|
||||
private lastRenderedTimeMs: number | null = null;
|
||||
private swayRotation = 0;
|
||||
private swaySpring = createSpringState(0);
|
||||
|
||||
constructor(config: Partial<CursorRenderConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CURSOR_CONFIG, ...config };
|
||||
this.state = new SmoothedCursorState(this.config);
|
||||
|
||||
this.container = new Container();
|
||||
this.container.label = 'cursor-overlay';
|
||||
this.container.label = "cursor-overlay";
|
||||
|
||||
this.clickRingGraphics = new Graphics();
|
||||
this.cursorShadowSprites = {};
|
||||
@@ -545,7 +642,8 @@ export class PixiCursorOverlay {
|
||||
|
||||
setMotionBlur(motionBlur: number) {
|
||||
this.config.motionBlur = Math.max(0, motionBlur);
|
||||
this.container.filters = this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
|
||||
this.container.filters =
|
||||
this.config.motionBlur > 0 ? [this.cursorMotionBlurFilter] : null;
|
||||
if (this.config.motionBlur <= 0) {
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
@@ -557,6 +655,10 @@ export class PixiCursorOverlay {
|
||||
this.config.clickBounce = Math.max(0, clickBounce);
|
||||
}
|
||||
|
||||
setSway(sway: number) {
|
||||
this.config.sway = clamp(sway, 0, 2);
|
||||
}
|
||||
|
||||
update(
|
||||
samples: CursorTelemetryPoint[],
|
||||
timeMs: number,
|
||||
@@ -564,10 +666,17 @@ export class PixiCursorOverlay {
|
||||
visible: boolean,
|
||||
freeze = false,
|
||||
): void {
|
||||
if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
|
||||
if (
|
||||
!visible ||
|
||||
samples.length === 0 ||
|
||||
viewport.width <= 0 ||
|
||||
viewport.height <= 0
|
||||
) {
|
||||
this.container.visible = false;
|
||||
this.lastRenderedPoint = null;
|
||||
this.lastRenderedTimeMs = null;
|
||||
this.swayRotation = 0;
|
||||
resetSpringState(this.swaySpring, 0);
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
return;
|
||||
}
|
||||
@@ -578,11 +687,15 @@ export class PixiCursorOverlay {
|
||||
return;
|
||||
}
|
||||
|
||||
const sameFrameTime = this.lastRenderedTimeMs !== null && Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001;
|
||||
const hasTimeDiscontinuity = this.lastRenderedTimeMs !== null
|
||||
&& Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS;
|
||||
const sameFrameTime =
|
||||
this.lastRenderedTimeMs !== null &&
|
||||
Math.abs(this.lastRenderedTimeMs - timeMs) < 0.0001;
|
||||
const hasTimeDiscontinuity =
|
||||
this.lastRenderedTimeMs !== null &&
|
||||
Math.abs(timeMs - this.lastRenderedTimeMs) > CURSOR_TIME_DISCONTINUITY_MS;
|
||||
const shouldFreezeCursorMotion = freeze || hasTimeDiscontinuity;
|
||||
|
||||
if (freeze || hasTimeDiscontinuity) {
|
||||
if (shouldFreezeCursorMotion) {
|
||||
if (!sameFrameTime || !this.lastRenderedPoint) {
|
||||
this.state.snapTo(target.cx, target.cy, timeMs);
|
||||
}
|
||||
@@ -594,29 +707,52 @@ export class PixiCursorOverlay {
|
||||
const px = viewport.x + this.state.x * viewport.width;
|
||||
const py = viewport.y + this.state.y * viewport.height;
|
||||
const h = this.config.dotRadius * getCursorViewportScale(viewport);
|
||||
const { cursorType, clickBounceProgress, clickProgress } = getCursorVisualState(samples, timeMs);
|
||||
const spriteKey = (cursorType in this.cursorSprites ? cursorType : 'arrow') as CursorAssetKey;
|
||||
const { cursorType, clickBounceProgress, clickProgress } =
|
||||
getCursorVisualState(samples, timeMs);
|
||||
const spriteKey = (
|
||||
cursorType in this.cursorSprites ? cursorType : "arrow"
|
||||
) as CursorAssetKey;
|
||||
const asset = getCursorAsset(spriteKey);
|
||||
const shadowSprite = this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
|
||||
const shadowSprite =
|
||||
this.cursorShadowSprites[spriteKey] ?? this.cursorShadowSprites.arrow!;
|
||||
const sprite = this.cursorSprites[spriteKey] ?? this.cursorSprites.arrow!;
|
||||
const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * this.config.clickBounce));
|
||||
const bounceScale = Math.max(
|
||||
0.72,
|
||||
1 -
|
||||
Math.sin(clickBounceProgress * Math.PI) *
|
||||
(0.08 * this.config.clickBounce),
|
||||
);
|
||||
const scaledH = h;
|
||||
const swayRotation = this.updateCursorSway(
|
||||
px,
|
||||
py,
|
||||
timeMs,
|
||||
shouldFreezeCursorMotion,
|
||||
);
|
||||
|
||||
this.clickRingGraphics.clear();
|
||||
drawClickRing(this.clickRingGraphics, px, py, h, clickProgress);
|
||||
|
||||
for (const [key, currentShadowSprite] of Object.entries(this.cursorShadowSprites) as Array<[CursorAssetKey, Sprite]>) {
|
||||
for (const [key, currentShadowSprite] of Object.entries(
|
||||
this.cursorShadowSprites,
|
||||
) as Array<[CursorAssetKey, Sprite]>) {
|
||||
currentShadowSprite.visible = key === spriteKey;
|
||||
}
|
||||
|
||||
for (const [key, currentSprite] of Object.entries(this.cursorSprites) as Array<[CursorAssetKey, Sprite]>) {
|
||||
for (const [key, currentSprite] of Object.entries(
|
||||
this.cursorSprites,
|
||||
) as Array<[CursorAssetKey, Sprite]>) {
|
||||
currentSprite.visible = key === spriteKey;
|
||||
}
|
||||
|
||||
if (shadowSprite) {
|
||||
shadowSprite.height = scaledH * bounceScale;
|
||||
shadowSprite.width = scaledH * bounceScale * asset.aspectRatio;
|
||||
shadowSprite.position.set(px + CURSOR_SHADOW_OFFSET_X, py + CURSOR_SHADOW_OFFSET_Y);
|
||||
shadowSprite.position.set(
|
||||
px + CURSOR_SHADOW_OFFSET_X,
|
||||
py + CURSOR_SHADOW_OFFSET_Y,
|
||||
);
|
||||
shadowSprite.rotation = swayRotation;
|
||||
}
|
||||
|
||||
if (sprite) {
|
||||
@@ -624,15 +760,60 @@ export class PixiCursorOverlay {
|
||||
sprite.height = scaledH * bounceScale;
|
||||
sprite.width = scaledH * bounceScale * asset.aspectRatio;
|
||||
sprite.position.set(px, py);
|
||||
sprite.rotation = swayRotation;
|
||||
}
|
||||
|
||||
this.applyCursorMotionBlur(px, py, timeMs, freeze);
|
||||
this.applyCursorMotionBlur(px, py, timeMs, shouldFreezeCursorMotion);
|
||||
this.lastRenderedPoint = { px, py };
|
||||
this.lastRenderedTimeMs = timeMs;
|
||||
}
|
||||
|
||||
private applyCursorMotionBlur(px: number, py: number, timeMs: number, freeze: boolean) {
|
||||
if (freeze || this.config.motionBlur <= 0 || !this.lastRenderedPoint || this.lastRenderedTimeMs === null) {
|
||||
private updateCursorSway(
|
||||
px: number,
|
||||
py: number,
|
||||
timeMs: number,
|
||||
freeze: boolean,
|
||||
) {
|
||||
const deltaMs =
|
||||
this.lastRenderedTimeMs === null || freeze
|
||||
? 1000 / 60
|
||||
: Math.max(1, timeMs - this.lastRenderedTimeMs);
|
||||
const targetRotation =
|
||||
!freeze && this.lastRenderedPoint && this.lastRenderedTimeMs !== null
|
||||
? computeCursorSwayRotation(
|
||||
px - this.lastRenderedPoint.px,
|
||||
py - this.lastRenderedPoint.py,
|
||||
timeMs - this.lastRenderedTimeMs,
|
||||
this.config.sway,
|
||||
)
|
||||
: 0;
|
||||
|
||||
this.swayRotation = stepSpringValue(
|
||||
this.swaySpring,
|
||||
targetRotation,
|
||||
deltaMs,
|
||||
getCursorSwaySpringConfig(this.config.smoothingFactor),
|
||||
);
|
||||
|
||||
if (Math.abs(this.swayRotation) < 0.0001 && targetRotation === 0) {
|
||||
this.swayRotation = 0;
|
||||
}
|
||||
|
||||
return this.swayRotation;
|
||||
}
|
||||
|
||||
private applyCursorMotionBlur(
|
||||
px: number,
|
||||
py: number,
|
||||
timeMs: number,
|
||||
freeze: boolean,
|
||||
) {
|
||||
if (
|
||||
freeze ||
|
||||
this.config.motionBlur <= 0 ||
|
||||
!this.lastRenderedPoint ||
|
||||
this.lastRenderedTimeMs === null
|
||||
) {
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
this.cursorMotionBlurFilter.offset = 0;
|
||||
@@ -642,15 +823,20 @@ export class PixiCursorOverlay {
|
||||
const deltaMs = Math.max(1, timeMs - this.lastRenderedTimeMs);
|
||||
const dx = px - this.lastRenderedPoint.px;
|
||||
const dy = py - this.lastRenderedPoint.py;
|
||||
const velocityScale = (1000 / deltaMs) * this.config.motionBlur * CURSOR_MOTION_BLUR_BASE_MULTIPLIER;
|
||||
const velocityScale =
|
||||
(1000 / deltaMs) *
|
||||
this.config.motionBlur *
|
||||
CURSOR_MOTION_BLUR_BASE_MULTIPLIER;
|
||||
const velocity = {
|
||||
x: dx * velocityScale,
|
||||
y: dy * velocityScale,
|
||||
};
|
||||
const magnitude = Math.hypot(velocity.x, velocity.y);
|
||||
|
||||
this.cursorMotionBlurFilter.velocity = magnitude > 0.05 ? velocity : { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5;
|
||||
this.cursorMotionBlurFilter.velocity =
|
||||
magnitude > 0.05 ? velocity : { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize =
|
||||
magnitude > 3 ? 9 : magnitude > 1 ? 7 : 5;
|
||||
this.cursorMotionBlurFilter.offset = magnitude > 0.5 ? -0.25 : 0;
|
||||
}
|
||||
|
||||
@@ -668,6 +854,8 @@ export class PixiCursorOverlay {
|
||||
this.container.visible = false;
|
||||
this.lastRenderedPoint = null;
|
||||
this.lastRenderedTimeMs = null;
|
||||
this.swayRotation = 0;
|
||||
resetSpringState(this.swaySpring, 0);
|
||||
this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
|
||||
this.cursorMotionBlurFilter.kernelSize = 5;
|
||||
this.cursorMotionBlurFilter.offset = 0;
|
||||
@@ -691,7 +879,8 @@ export function drawCursorOnCanvas(
|
||||
smoothedState: SmoothedCursorState,
|
||||
config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG,
|
||||
): void {
|
||||
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return;
|
||||
if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0)
|
||||
return;
|
||||
|
||||
const target = interpolateCursorPosition(samples, timeMs);
|
||||
if (!target) return;
|
||||
@@ -701,10 +890,18 @@ export function drawCursorOnCanvas(
|
||||
const px = viewport.x + smoothedState.x * viewport.width;
|
||||
const py = viewport.y + smoothedState.y * viewport.height;
|
||||
const h = config.dotRadius * getCursorViewportScale(viewport);
|
||||
const { cursorType, clickBounceProgress } = getCursorVisualState(samples, timeMs);
|
||||
const spriteKey = (cursorType && loadedCursorAssets[cursorType] ? cursorType : 'arrow') as CursorAssetKey;
|
||||
const { cursorType, clickBounceProgress } = getCursorVisualState(
|
||||
samples,
|
||||
timeMs,
|
||||
);
|
||||
const spriteKey = (
|
||||
cursorType && loadedCursorAssets[cursorType] ? cursorType : "arrow"
|
||||
) as CursorAssetKey;
|
||||
const asset = getCursorAsset(spriteKey);
|
||||
const bounceScale = Math.max(0.72, 1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce));
|
||||
const bounceScale = Math.max(
|
||||
0.72,
|
||||
1 - Math.sin(clickBounceProgress * Math.PI) * (0.08 * config.clickBounce),
|
||||
);
|
||||
|
||||
ctx.save();
|
||||
ctx.filter = CURSOR_SVG_DROP_SHADOW_FILTER;
|
||||
@@ -714,8 +911,13 @@ export function drawCursorOnCanvas(
|
||||
const hotspotX = asset.anchorX * drawWidth;
|
||||
const hotspotY = asset.anchorY * drawHeight;
|
||||
ctx.globalAlpha = config.dotAlpha;
|
||||
ctx.drawImage(asset.image, px - hotspotX, py - hotspotY, drawWidth, drawHeight);
|
||||
ctx.drawImage(
|
||||
asset.image,
|
||||
px - hotspotX,
|
||||
py - hotspotY,
|
||||
drawWidth,
|
||||
drawHeight,
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
computeCursorSwayRotation,
|
||||
fromCursorSwaySliderValue,
|
||||
toCursorSwaySliderValue,
|
||||
} from "./cursorSway";
|
||||
|
||||
describe("computeCursorSwayRotation", () => {
|
||||
it("returns zero when sway is disabled or there is no movement", () => {
|
||||
expect(computeCursorSwayRotation(120, 0, 16, 0)).toBe(0);
|
||||
expect(computeCursorSwayRotation(0, 0, 16, 1)).toBe(0);
|
||||
});
|
||||
|
||||
it("leans with the motion direction", () => {
|
||||
expect(computeCursorSwayRotation(120, 0, 16, 1)).toBeGreaterThan(0);
|
||||
expect(computeCursorSwayRotation(-120, 0, 16, 1)).toBeLessThan(0);
|
||||
expect(computeCursorSwayRotation(0, 120, 16, 1)).toBeGreaterThan(0);
|
||||
expect(computeCursorSwayRotation(0, -120, 16, 1)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("increases with faster movement for the same direction", () => {
|
||||
const slow = Math.abs(computeCursorSwayRotation(24, 0, 48, 1));
|
||||
const fast = Math.abs(computeCursorSwayRotation(120, 0, 16, 1));
|
||||
|
||||
expect(fast).toBeGreaterThan(slow);
|
||||
});
|
||||
|
||||
it("maps a 2x slider value to a 6x sway intensity", () => {
|
||||
expect(computeCursorSwayRotation(-140, 0, 100, 2)).toBeCloseTo(-(Math.PI / 3), 6);
|
||||
});
|
||||
|
||||
it("maps a 1x slider value to the previous 2x sway strength", () => {
|
||||
expect(fromCursorSwaySliderValue(1)).toBe(2);
|
||||
expect(toCursorSwaySliderValue(2)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { clampDeltaMs } from "./motionSmoothing";
|
||||
|
||||
const CURSOR_SWAY_MAX_ROTATION = Math.PI / 18;
|
||||
const CURSOR_SWAY_SPEED_REFERENCE = 1400;
|
||||
const CURSOR_SWAY_VERTICAL_WEIGHT = 0.65;
|
||||
const CURSOR_SWAY_INTENSITY_SCALE = 3;
|
||||
export const CURSOR_SWAY_SLIDER_SCALE = 2;
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function computeCursorSwayRotation(dx: number, dy: number, deltaMs: number, sway: number) {
|
||||
if (sway <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const distance = Math.hypot(dx, dy);
|
||||
if (!Number.isFinite(distance) || distance < 0.01) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const speedPxPerSecond = distance / (clampDeltaMs(deltaMs) / 1000);
|
||||
const speedFactor = clamp(speedPxPerSecond / CURSOR_SWAY_SPEED_REFERENCE, 0, 1);
|
||||
if (speedFactor <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const directionalBias = clamp((dx + dy * CURSOR_SWAY_VERTICAL_WEIGHT) / distance, -1, 1);
|
||||
return (
|
||||
directionalBias * speedFactor * CURSOR_SWAY_MAX_ROTATION * sway * CURSOR_SWAY_INTENSITY_SCALE
|
||||
);
|
||||
}
|
||||
|
||||
export function toCursorSwaySliderValue(sway: number) {
|
||||
return sway / CURSOR_SWAY_SLIDER_SCALE;
|
||||
}
|
||||
|
||||
export function fromCursorSwaySliderValue(sliderValue: number) {
|
||||
return sliderValue * CURSOR_SWAY_SLIDER_SCALE;
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
"folderPath": "Path: /{{name}}/",
|
||||
"openVideoFile": "Open video file",
|
||||
"openProject": "Open project",
|
||||
"hideHudFromVideo": "Hide HUD from recording",
|
||||
"showHudInVideo": "Show HUD in recording",
|
||||
"hideHud": "Hide HUD",
|
||||
"closeApp": "Close App"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"off": "Off",
|
||||
"cursorMotionBlur": "Cursor Motion Blur",
|
||||
"cursorClickBounce": "Cursor Click Bounce",
|
||||
"cursorSway": "Cursor Sway",
|
||||
"shadow": "Shadow",
|
||||
"roundness": "Roundness",
|
||||
"padding": "Padding"
|
||||
@@ -47,8 +48,8 @@
|
||||
"quality": {
|
||||
"low": "Low",
|
||||
"medium": "Medium",
|
||||
"high": "High",
|
||||
"original": "Original"
|
||||
"high": "High",
|
||||
"original": "Original"
|
||||
},
|
||||
"loop": "Loop",
|
||||
"outputDimensions": "Output: {{dimensions}}px",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"folderPath": "Ruta: /{{name}}/",
|
||||
"openVideoFile": "Abrir archivo de video",
|
||||
"openProject": "Abrir proyecto",
|
||||
"hideHudFromVideo": "Ocultar HUD en la grabación",
|
||||
"showHudInVideo": "Mostrar HUD en la grabación",
|
||||
"hideHud": "Ocultar HUD",
|
||||
"closeApp": "Cerrar aplicación"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"off": "Desactivado",
|
||||
"cursorMotionBlur": "Desenfoque de movimiento del cursor",
|
||||
"cursorClickBounce": "Rebote de clic del cursor",
|
||||
"cursorSway": "Balanceo del cursor",
|
||||
"shadow": "Sombra",
|
||||
"roundness": "Redondez",
|
||||
"padding": "Relleno"
|
||||
@@ -47,8 +48,8 @@
|
||||
"quality": {
|
||||
"low": "Baja",
|
||||
"medium": "Media",
|
||||
"high": "Alta",
|
||||
"original": "Original"
|
||||
"high": "Alta",
|
||||
"original": "Original"
|
||||
},
|
||||
"loop": "Bucle",
|
||||
"outputDimensions": "Salida: {{dimensions}}px",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"folderPath": "路径:/{{name}}/",
|
||||
"openVideoFile": "打开视频文件",
|
||||
"openProject": "打开项目",
|
||||
"hideHudFromVideo": "在录制中隐藏 HUD",
|
||||
"showHudInVideo": "在录制中显示 HUD",
|
||||
"hideHud": "隐藏 HUD",
|
||||
"closeApp": "关闭应用"
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"off": "关",
|
||||
"cursorMotionBlur": "光标运动模糊",
|
||||
"cursorClickBounce": "光标点击弹跳",
|
||||
"cursorSway": "光标摆动",
|
||||
"shadow": "阴影",
|
||||
"roundness": "圆角",
|
||||
"padding": "内边距"
|
||||
|
||||
+235
-112
@@ -1,13 +1,40 @@
|
||||
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
|
||||
import { MotionBlurFilter } from 'pixi-filters/motion-blur';
|
||||
import type { ZoomRegion, CropRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
|
||||
import { ZOOM_DEPTH_SCALES } from '@/components/video-editor/types';
|
||||
import { getAssetPath, getRenderableAssetUrl } from '@/lib/assetPath';
|
||||
import { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
|
||||
import { applyZoomTransform, computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState } from '@/components/video-editor/videoPlayback/zoomTransform';
|
||||
import { DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX } from '@/components/video-editor/videoPlayback/constants';
|
||||
import { renderAnnotations } from './annotationRenderer';
|
||||
import { PixiCursorOverlay, DEFAULT_CURSOR_CONFIG, preloadCursorAssets } from '@/components/video-editor/videoPlayback/cursorRenderer';
|
||||
import {
|
||||
Application,
|
||||
Container,
|
||||
Sprite,
|
||||
Graphics,
|
||||
BlurFilter,
|
||||
Texture,
|
||||
} from "pixi.js";
|
||||
import { MotionBlurFilter } from "pixi-filters/motion-blur";
|
||||
import type {
|
||||
ZoomRegion,
|
||||
CropRegion,
|
||||
AnnotationRegion,
|
||||
SpeedRegion,
|
||||
CursorTelemetryPoint,
|
||||
} from "@/components/video-editor/types";
|
||||
import { ZOOM_DEPTH_SCALES } from "@/components/video-editor/types";
|
||||
import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
|
||||
import { findDominantRegion } from "@/components/video-editor/videoPlayback/zoomRegionUtils";
|
||||
import {
|
||||
applyZoomTransform,
|
||||
computeFocusFromTransform,
|
||||
computeZoomTransform,
|
||||
createMotionBlurState,
|
||||
type MotionBlurState,
|
||||
} from "@/components/video-editor/videoPlayback/zoomTransform";
|
||||
import {
|
||||
DEFAULT_FOCUS,
|
||||
ZOOM_SCALE_DEADZONE,
|
||||
ZOOM_TRANSLATION_DEADZONE_PX,
|
||||
} from "@/components/video-editor/videoPlayback/constants";
|
||||
import { renderAnnotations } from "./annotationRenderer";
|
||||
import {
|
||||
PixiCursorOverlay,
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
preloadCursorAssets,
|
||||
} from "@/components/video-editor/videoPlayback/cursorRenderer";
|
||||
|
||||
interface FrameRenderConfig {
|
||||
width: number;
|
||||
@@ -34,6 +61,7 @@ interface FrameRenderConfig {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorSway?: number;
|
||||
}
|
||||
|
||||
interface AnimationState {
|
||||
@@ -94,23 +122,29 @@ export class FrameRenderer {
|
||||
await preloadCursorAssets();
|
||||
} catch (error) {
|
||||
cursorOverlayEnabled = false;
|
||||
console.warn('[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.', error);
|
||||
console.warn(
|
||||
"[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Create canvas for rendering
|
||||
const canvas = document.createElement('canvas');
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = this.config.width;
|
||||
canvas.height = this.config.height;
|
||||
|
||||
|
||||
// Try to set colorSpace if supported (may not be available on all platforms)
|
||||
try {
|
||||
if (canvas && 'colorSpace' in canvas) {
|
||||
if (canvas && "colorSpace" in canvas) {
|
||||
// @ts-ignore
|
||||
canvas.colorSpace = 'srgb';
|
||||
canvas.colorSpace = "srgb";
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently ignore colorSpace errors on platforms that don't support it
|
||||
console.warn('[FrameRenderer] colorSpace not supported on this platform:', error);
|
||||
console.warn(
|
||||
"[FrameRenderer] colorSpace not supported on this platform:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// Initialize PixiJS with optimized settings for export performance
|
||||
@@ -135,10 +169,14 @@ export class FrameRenderer {
|
||||
|
||||
if (cursorOverlayEnabled) {
|
||||
this.cursorOverlay = new PixiCursorOverlay({
|
||||
dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4),
|
||||
smoothingFactor: this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
|
||||
dotRadius:
|
||||
DEFAULT_CURSOR_CONFIG.dotRadius * (this.config.cursorSize ?? 1.4),
|
||||
smoothingFactor:
|
||||
this.config.cursorSmoothing ?? DEFAULT_CURSOR_CONFIG.smoothingFactor,
|
||||
motionBlur: this.config.cursorMotionBlur ?? 0,
|
||||
clickBounce: this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
|
||||
clickBounce:
|
||||
this.config.cursorClickBounce ?? DEFAULT_CURSOR_CONFIG.clickBounce,
|
||||
sway: this.config.cursorSway ?? DEFAULT_CURSOR_CONFIG.sway,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -154,24 +192,28 @@ export class FrameRenderer {
|
||||
this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
|
||||
|
||||
// Setup composite canvas for final output with shadows
|
||||
this.compositeCanvas = document.createElement('canvas');
|
||||
this.compositeCanvas = document.createElement("canvas");
|
||||
this.compositeCanvas.width = this.config.width;
|
||||
this.compositeCanvas.height = this.config.height;
|
||||
this.compositeCtx = this.compositeCanvas.getContext('2d', { willReadFrequently: false });
|
||||
|
||||
this.compositeCtx = this.compositeCanvas.getContext("2d", {
|
||||
willReadFrequently: false,
|
||||
});
|
||||
|
||||
if (!this.compositeCtx) {
|
||||
throw new Error('Failed to get 2D context for composite canvas');
|
||||
throw new Error("Failed to get 2D context for composite canvas");
|
||||
}
|
||||
|
||||
// Setup shadow canvas if needed
|
||||
if (this.config.showShadow) {
|
||||
this.shadowCanvas = document.createElement('canvas');
|
||||
this.shadowCanvas = document.createElement("canvas");
|
||||
this.shadowCanvas.width = this.config.width;
|
||||
this.shadowCanvas.height = this.config.height;
|
||||
this.shadowCtx = this.shadowCanvas.getContext('2d', { willReadFrequently: false });
|
||||
|
||||
this.shadowCtx = this.shadowCanvas.getContext("2d", {
|
||||
willReadFrequently: false,
|
||||
});
|
||||
|
||||
if (!this.shadowCtx) {
|
||||
throw new Error('Failed to get 2D context for shadow canvas');
|
||||
throw new Error("Failed to get 2D context for shadow canvas");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,44 +227,55 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
private async setupBackground(): Promise<void> {
|
||||
const wallpaper = await this.resolveWallpaperForExport(this.config.wallpaper);
|
||||
const wallpaper = await this.resolveWallpaperForExport(
|
||||
this.config.wallpaper,
|
||||
);
|
||||
|
||||
// Create background canvas for separate rendering (not affected by zoom)
|
||||
const bgCanvas = document.createElement('canvas');
|
||||
const bgCanvas = document.createElement("canvas");
|
||||
bgCanvas.width = this.config.width;
|
||||
bgCanvas.height = this.config.height;
|
||||
const bgCtx = bgCanvas.getContext('2d')!;
|
||||
const bgCtx = bgCanvas.getContext("2d")!;
|
||||
|
||||
try {
|
||||
// Render background based on type
|
||||
if (wallpaper.startsWith('file://') || wallpaper.startsWith('data:') || wallpaper.startsWith('/') || wallpaper.startsWith('http')) {
|
||||
if (
|
||||
wallpaper.startsWith("file://") ||
|
||||
wallpaper.startsWith("data:") ||
|
||||
wallpaper.startsWith("/") ||
|
||||
wallpaper.startsWith("http")
|
||||
) {
|
||||
// Image background
|
||||
const img = new Image();
|
||||
const imageUrl = await this.resolveWallpaperImageUrl(wallpaper);
|
||||
// Don't set crossOrigin for same-origin images to avoid CORS taint.
|
||||
if (
|
||||
imageUrl.startsWith('http')
|
||||
&& window.location.origin
|
||||
&& !imageUrl.startsWith(window.location.origin)
|
||||
imageUrl.startsWith("http") &&
|
||||
window.location.origin &&
|
||||
!imageUrl.startsWith(window.location.origin)
|
||||
) {
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.crossOrigin = "anonymous";
|
||||
}
|
||||
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = (err) => {
|
||||
console.error('[FrameRenderer] Failed to load background image:', imageUrl, err);
|
||||
console.error(
|
||||
"[FrameRenderer] Failed to load background image:",
|
||||
imageUrl,
|
||||
err,
|
||||
);
|
||||
reject(new Error(`Failed to load background image: ${imageUrl}`));
|
||||
};
|
||||
img.src = imageUrl;
|
||||
});
|
||||
|
||||
|
||||
// Draw the image using cover and center positioning
|
||||
const imgAspect = img.width / img.height;
|
||||
const canvasAspect = this.config.width / this.config.height;
|
||||
|
||||
|
||||
let drawWidth, drawHeight, drawX, drawY;
|
||||
|
||||
|
||||
if (imgAspect > canvasAspect) {
|
||||
drawHeight = this.config.height;
|
||||
drawWidth = drawHeight * imgAspect;
|
||||
@@ -234,26 +287,32 @@ export class FrameRenderer {
|
||||
drawX = 0;
|
||||
drawY = (this.config.height - drawHeight) / 2;
|
||||
}
|
||||
|
||||
|
||||
bgCtx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
|
||||
} else if (wallpaper.startsWith('#')) {
|
||||
} else if (wallpaper.startsWith("#")) {
|
||||
bgCtx.fillStyle = wallpaper;
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
} else if (wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
|
||||
|
||||
const gradientMatch = wallpaper.match(/(linear|radial)-gradient\((.+)\)/);
|
||||
} else if (
|
||||
wallpaper.startsWith("linear-gradient") ||
|
||||
wallpaper.startsWith("radial-gradient")
|
||||
) {
|
||||
const gradientMatch = wallpaper.match(
|
||||
/(linear|radial)-gradient\((.+)\)/,
|
||||
);
|
||||
if (gradientMatch) {
|
||||
const [, type, params] = gradientMatch;
|
||||
const parts = params.split(',').map(s => s.trim());
|
||||
|
||||
const parts = params.split(",").map((s) => s.trim());
|
||||
|
||||
let gradient: CanvasGradient;
|
||||
|
||||
if (type === 'linear') {
|
||||
|
||||
if (type === "linear") {
|
||||
gradient = bgCtx.createLinearGradient(0, 0, 0, this.config.height);
|
||||
parts.forEach((part, index) => {
|
||||
if (part.startsWith('to ') || part.includes('deg')) return;
|
||||
|
||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
||||
if (part.startsWith("to ") || part.includes("deg")) return;
|
||||
|
||||
const colorMatch = part.match(
|
||||
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
|
||||
);
|
||||
if (colorMatch) {
|
||||
const color = colorMatch[1];
|
||||
const position = index / (parts.length - 1);
|
||||
@@ -265,9 +324,11 @@ export class FrameRenderer {
|
||||
const cy = this.config.height / 2;
|
||||
const radius = Math.max(this.config.width, this.config.height) / 2;
|
||||
gradient = bgCtx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
|
||||
|
||||
parts.forEach((part, index) => {
|
||||
const colorMatch = part.match(/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/);
|
||||
const colorMatch = part.match(
|
||||
/^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|[a-z]+)/,
|
||||
);
|
||||
if (colorMatch) {
|
||||
const color = colorMatch[1];
|
||||
const position = index / (parts.length - 1);
|
||||
@@ -275,12 +336,14 @@ export class FrameRenderer {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
bgCtx.fillStyle = gradient;
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
} else {
|
||||
console.warn('[FrameRenderer] Could not parse gradient, using black fallback');
|
||||
bgCtx.fillStyle = '#000000';
|
||||
console.warn(
|
||||
"[FrameRenderer] Could not parse gradient, using black fallback",
|
||||
);
|
||||
bgCtx.fillStyle = "#000000";
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
} else {
|
||||
@@ -288,8 +351,11 @@ export class FrameRenderer {
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[FrameRenderer] Error setting up background, using fallback:', error);
|
||||
bgCtx.fillStyle = '#000000';
|
||||
console.error(
|
||||
"[FrameRenderer] Error setting up background, using fallback:",
|
||||
error,
|
||||
);
|
||||
bgCtx.fillStyle = "#000000";
|
||||
bgCtx.fillRect(0, 0, this.config.width, this.config.height);
|
||||
}
|
||||
|
||||
@@ -299,15 +365,18 @@ export class FrameRenderer {
|
||||
|
||||
private async resolveWallpaperImageUrl(wallpaper: string): Promise<string> {
|
||||
if (
|
||||
wallpaper.startsWith('file://')
|
||||
|| wallpaper.startsWith('data:')
|
||||
|| wallpaper.startsWith('http')
|
||||
wallpaper.startsWith("file://") ||
|
||||
wallpaper.startsWith("data:") ||
|
||||
wallpaper.startsWith("http")
|
||||
) {
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ''));
|
||||
if (resolved.startsWith('/') && window.location.protocol.startsWith('http')) {
|
||||
const resolved = await getAssetPath(wallpaper.replace(/^\/+/, ""));
|
||||
if (
|
||||
resolved.startsWith("/") &&
|
||||
window.location.protocol.startsWith("http")
|
||||
) {
|
||||
return `${window.location.origin}${resolved}`;
|
||||
}
|
||||
|
||||
@@ -319,14 +388,19 @@ export class FrameRenderer {
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
if (wallpaper.startsWith('#') || wallpaper.startsWith('linear-gradient') || wallpaper.startsWith('radial-gradient')) {
|
||||
if (
|
||||
wallpaper.startsWith("#") ||
|
||||
wallpaper.startsWith("linear-gradient") ||
|
||||
wallpaper.startsWith("radial-gradient")
|
||||
) {
|
||||
return wallpaper;
|
||||
}
|
||||
|
||||
const looksLikeAbsoluteFilePath = wallpaper.startsWith('/')
|
||||
&& !wallpaper.startsWith('//')
|
||||
&& !wallpaper.startsWith('/wallpapers/')
|
||||
&& !wallpaper.startsWith('/app-icons/');
|
||||
const looksLikeAbsoluteFilePath =
|
||||
wallpaper.startsWith("/") &&
|
||||
!wallpaper.startsWith("//") &&
|
||||
!wallpaper.startsWith("/wallpapers/") &&
|
||||
!wallpaper.startsWith("/app-icons/");
|
||||
|
||||
const wallpaperAsset = looksLikeAbsoluteFilePath
|
||||
? `file://${encodeURI(wallpaper)}`
|
||||
@@ -337,7 +411,7 @@ export class FrameRenderer {
|
||||
|
||||
async renderFrame(videoFrame: VideoFrame, timestamp: number): Promise<void> {
|
||||
if (!this.app || !this.videoContainer || !this.cameraContainer) {
|
||||
throw new Error('Renderer not initialized');
|
||||
throw new Error("Renderer not initialized");
|
||||
}
|
||||
|
||||
this.currentVideoTime = timestamp / 1000000;
|
||||
@@ -377,13 +451,13 @@ export class FrameRenderer {
|
||||
}
|
||||
|
||||
const TICKS_PER_FRAME = 1;
|
||||
|
||||
|
||||
let maxMotionIntensity = 0;
|
||||
for (let i = 0; i < TICKS_PER_FRAME; i++) {
|
||||
const motionIntensity = this.updateAnimationState(timeMs);
|
||||
maxMotionIntensity = Math.max(maxMotionIntensity, motionIntensity);
|
||||
}
|
||||
|
||||
|
||||
// Apply transform once with maximum motion intensity from all ticks
|
||||
applyZoomTransform({
|
||||
cameraContainer: this.cameraContainer,
|
||||
@@ -415,7 +489,11 @@ export class FrameRenderer {
|
||||
this.compositeWithShadows();
|
||||
|
||||
// Render annotations on top if present
|
||||
if (this.config.annotationRegions && this.config.annotationRegions.length > 0 && this.compositeCtx) {
|
||||
if (
|
||||
this.config.annotationRegions &&
|
||||
this.config.annotationRegions.length > 0 &&
|
||||
this.compositeCtx
|
||||
) {
|
||||
// Calculate scale factor based on export vs preview dimensions
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
@@ -429,14 +507,19 @@ export class FrameRenderer {
|
||||
this.config.width,
|
||||
this.config.height,
|
||||
timeMs,
|
||||
scaleFactor
|
||||
scaleFactor,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private updateLayout(): void {
|
||||
if (!this.app || !this.videoSprite || !this.maskGraphics || !this.videoContainer) return;
|
||||
if (
|
||||
!this.app ||
|
||||
!this.videoSprite ||
|
||||
!this.maskGraphics ||
|
||||
!this.videoContainer
|
||||
)
|
||||
return;
|
||||
|
||||
const { width, height } = this.config;
|
||||
const { cropRegion, borderRadius = 0, padding = 0 } = this.config;
|
||||
@@ -451,13 +534,16 @@ export class FrameRenderer {
|
||||
|
||||
const croppedVideoWidth = videoWidth * (cropEndX - cropStartX);
|
||||
const croppedVideoHeight = videoHeight * (cropEndY - cropStartY);
|
||||
|
||||
|
||||
// Calculate scale to fit in viewport
|
||||
// Padding is a percentage (0-100), where 50% ~ 0.8 scale
|
||||
const paddingScale = 1.0 - (padding / 100) * 0.4;
|
||||
const viewportWidth = width * paddingScale;
|
||||
const viewportHeight = height * paddingScale;
|
||||
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
|
||||
const scale = Math.min(
|
||||
viewportWidth / croppedVideoWidth,
|
||||
viewportHeight / croppedVideoHeight,
|
||||
);
|
||||
|
||||
this.videoSprite.scale.set(scale);
|
||||
|
||||
@@ -468,8 +554,8 @@ export class FrameRenderer {
|
||||
const centerOffsetX = (width - croppedDisplayWidth) / 2;
|
||||
const centerOffsetY = (height - croppedDisplayHeight) / 2;
|
||||
|
||||
const spriteX = centerOffsetX - (cropRegion.x * fullVideoDisplayWidth);
|
||||
const spriteY = centerOffsetY - (cropRegion.y * fullVideoDisplayHeight);
|
||||
const spriteX = centerOffsetX - cropRegion.x * fullVideoDisplayWidth;
|
||||
const spriteY = centerOffsetY - cropRegion.y * fullVideoDisplayHeight;
|
||||
this.videoSprite.position.set(spriteX, spriteY);
|
||||
|
||||
this.videoContainer.position.set(0, 0);
|
||||
@@ -477,11 +563,20 @@ export class FrameRenderer {
|
||||
// scale border radius by export/preview canvas ratio
|
||||
const previewWidth = this.config.previewWidth || 1920;
|
||||
const previewHeight = this.config.previewHeight || 1080;
|
||||
const canvasScaleFactor = Math.min(width / previewWidth, height / previewHeight);
|
||||
const canvasScaleFactor = Math.min(
|
||||
width / previewWidth,
|
||||
height / previewHeight,
|
||||
);
|
||||
const scaledBorderRadius = borderRadius * canvasScaleFactor;
|
||||
|
||||
|
||||
this.maskGraphics.clear();
|
||||
this.maskGraphics.roundRect(centerOffsetX, centerOffsetY, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
|
||||
this.maskGraphics.roundRect(
|
||||
centerOffsetX,
|
||||
centerOffsetY,
|
||||
croppedDisplayWidth,
|
||||
croppedDisplayHeight,
|
||||
scaledBorderRadius,
|
||||
);
|
||||
this.maskGraphics.fill({ color: 0xffffff });
|
||||
|
||||
// Cache layout info
|
||||
@@ -490,17 +585,26 @@ export class FrameRenderer {
|
||||
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
|
||||
baseScale: scale,
|
||||
baseOffset: { x: spriteX, y: spriteY },
|
||||
maskRect: { x: centerOffsetX, y: centerOffsetY, width: croppedDisplayWidth, height: croppedDisplayHeight },
|
||||
maskRect: {
|
||||
x: centerOffsetX,
|
||||
y: centerOffsetY,
|
||||
width: croppedDisplayWidth,
|
||||
height: croppedDisplayHeight,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private updateAnimationState(timeMs: number): number {
|
||||
if (!this.cameraContainer || !this.layoutCache) return 0;
|
||||
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(this.config.zoomRegions, timeMs, {
|
||||
connectZooms: this.config.connectZooms,
|
||||
});
|
||||
|
||||
const { region, strength, blendedScale, transition } = findDominantRegion(
|
||||
this.config.zoomRegions,
|
||||
timeMs,
|
||||
{
|
||||
connectZooms: this.config.connectZooms,
|
||||
},
|
||||
);
|
||||
|
||||
const defaultFocus = DEFAULT_FOCUS;
|
||||
let targetScaleFactor = 1;
|
||||
let targetFocus = { ...defaultFocus };
|
||||
@@ -509,7 +613,7 @@ export class FrameRenderer {
|
||||
if (region && strength > 0) {
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = region.focus;
|
||||
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
targetFocus = regionFocus;
|
||||
targetProgress = strength;
|
||||
@@ -533,9 +637,15 @@ export class FrameRenderer {
|
||||
});
|
||||
|
||||
const interpolatedTransform = {
|
||||
scale: startTransform.scale + (endTransform.scale - startTransform.scale) * transition.progress,
|
||||
x: startTransform.x + (endTransform.x - startTransform.x) * transition.progress,
|
||||
y: startTransform.y + (endTransform.y - startTransform.y) * transition.progress,
|
||||
scale:
|
||||
startTransform.scale +
|
||||
(endTransform.scale - startTransform.scale) * transition.progress,
|
||||
x:
|
||||
startTransform.x +
|
||||
(endTransform.x - startTransform.x) * transition.progress,
|
||||
y:
|
||||
startTransform.y +
|
||||
(endTransform.y - startTransform.y) * transition.progress,
|
||||
};
|
||||
|
||||
targetScaleFactor = interpolatedTransform.scale;
|
||||
@@ -570,15 +680,18 @@ export class FrameRenderer {
|
||||
focusY: state.focusY,
|
||||
});
|
||||
|
||||
state.appliedScale = Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||
? projectedTransform.scale
|
||||
: projectedTransform.scale;
|
||||
state.x = Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.x
|
||||
: projectedTransform.x;
|
||||
state.y = Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.y
|
||||
: projectedTransform.y;
|
||||
state.appliedScale =
|
||||
Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
|
||||
? projectedTransform.scale
|
||||
: projectedTransform.scale;
|
||||
state.x =
|
||||
Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.x
|
||||
: projectedTransform.x;
|
||||
state.y =
|
||||
Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
|
||||
? projectedTransform.y
|
||||
: projectedTransform.y;
|
||||
|
||||
this.lastMotionVector = {
|
||||
x: state.x - prevX,
|
||||
@@ -588,7 +701,8 @@ export class FrameRenderer {
|
||||
return Math.max(
|
||||
Math.abs(state.appliedScale - prevScale),
|
||||
Math.abs(state.x - prevX) / Math.max(1, this.layoutCache.stageSize.width),
|
||||
Math.abs(state.y - prevY) / Math.max(1, this.layoutCache.stageSize.height)
|
||||
Math.abs(state.y - prevY) /
|
||||
Math.max(1, this.layoutCache.stageSize.height),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -606,7 +720,7 @@ export class FrameRenderer {
|
||||
// Step 1: Draw background layer (with optional blur, not affected by zoom)
|
||||
if (this.backgroundSprite) {
|
||||
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
|
||||
|
||||
|
||||
if (this.config.backgroundBlur > 0) {
|
||||
ctx.save();
|
||||
ctx.filter = `blur(${this.config.backgroundBlur * 3}px)`;
|
||||
@@ -616,15 +730,22 @@ export class FrameRenderer {
|
||||
ctx.drawImage(bgCanvas, 0, 0, w, h);
|
||||
}
|
||||
} else {
|
||||
console.warn('[FrameRenderer] No background sprite found during compositing!');
|
||||
console.warn(
|
||||
"[FrameRenderer] No background sprite found during compositing!",
|
||||
);
|
||||
}
|
||||
|
||||
// Draw video layer with shadows on top of background
|
||||
if (this.config.showShadow && this.config.shadowIntensity > 0 && this.shadowCanvas && this.shadowCtx) {
|
||||
if (
|
||||
this.config.showShadow &&
|
||||
this.config.shadowIntensity > 0 &&
|
||||
this.shadowCanvas &&
|
||||
this.shadowCtx
|
||||
) {
|
||||
const shadowCtx = this.shadowCtx;
|
||||
shadowCtx.clearRect(0, 0, w, h);
|
||||
shadowCtx.save();
|
||||
|
||||
|
||||
// Calculate shadow parameters based on intensity (0-1)
|
||||
const intensity = this.config.shadowIntensity;
|
||||
const baseBlur1 = 48 * intensity;
|
||||
@@ -634,8 +755,8 @@ export class FrameRenderer {
|
||||
const baseAlpha2 = 0.5 * intensity;
|
||||
const baseAlpha3 = 0.3 * intensity;
|
||||
const baseOffset = 12 * intensity;
|
||||
|
||||
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset/3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset/6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
|
||||
|
||||
shadowCtx.filter = `drop-shadow(0 ${baseOffset}px ${baseBlur1}px rgba(0,0,0,${baseAlpha1})) drop-shadow(0 ${baseOffset / 3}px ${baseBlur2}px rgba(0,0,0,${baseAlpha2})) drop-shadow(0 ${baseOffset / 6}px ${baseBlur3}px rgba(0,0,0,${baseAlpha3}))`;
|
||||
shadowCtx.drawImage(videoCanvas, 0, 0, w, h);
|
||||
shadowCtx.restore();
|
||||
ctx.drawImage(this.shadowCanvas, 0, 0, w, h);
|
||||
@@ -646,12 +767,11 @@ export class FrameRenderer {
|
||||
|
||||
getCanvas(): HTMLCanvasElement {
|
||||
if (!this.compositeCanvas) {
|
||||
throw new Error('Renderer not initialized');
|
||||
throw new Error("Renderer not initialized");
|
||||
}
|
||||
return this.compositeCanvas;
|
||||
}
|
||||
|
||||
|
||||
destroy(): void {
|
||||
if (this.videoSprite) {
|
||||
const videoTexture = this.videoSprite.texture;
|
||||
@@ -661,7 +781,11 @@ export class FrameRenderer {
|
||||
}
|
||||
this.backgroundSprite = null;
|
||||
if (this.app) {
|
||||
this.app.destroy(true, { children: true, texture: false, textureSource: false });
|
||||
this.app.destroy(true, {
|
||||
children: true,
|
||||
texture: false,
|
||||
textureSource: false,
|
||||
});
|
||||
this.app = null;
|
||||
}
|
||||
this.cameraContainer = null;
|
||||
@@ -679,4 +803,3 @@ export class FrameRenderer {
|
||||
this.compositeCtx = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import GIF from 'gif.js';
|
||||
import type { ExportProgress, ExportResult, GifFrameRate, GifSizePreset, GIF_SIZE_PRESETS } from './types';
|
||||
import { StreamingVideoDecoder } from './streamingDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
|
||||
import GIF from "gif.js";
|
||||
import type {
|
||||
ExportProgress,
|
||||
ExportResult,
|
||||
GifFrameRate,
|
||||
GifSizePreset,
|
||||
GIF_SIZE_PRESETS,
|
||||
} from "./types";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import type {
|
||||
ZoomRegion,
|
||||
CropRegion,
|
||||
TrimRegion,
|
||||
AnnotationRegion,
|
||||
SpeedRegion,
|
||||
CursorTelemetryPoint,
|
||||
} from "@/components/video-editor/types";
|
||||
|
||||
const GIF_WORKER_URL = new URL('gif.js/dist/gif.worker.js', import.meta.url).toString();
|
||||
const GIF_WORKER_URL = new URL(
|
||||
"gif.js/dist/gif.worker.js",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
|
||||
interface GifExporterConfig {
|
||||
videoUrl: string;
|
||||
@@ -33,6 +49,7 @@ interface GifExporterConfig {
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorSway?: number;
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
@@ -50,13 +67,13 @@ export function calculateOutputDimensions(
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sizePreset: GifSizePreset,
|
||||
sizePresets: typeof GIF_SIZE_PRESETS
|
||||
sizePresets: typeof GIF_SIZE_PRESETS,
|
||||
): { width: number; height: number } {
|
||||
const preset = sizePresets[sizePreset];
|
||||
const maxHeight = preset.maxHeight;
|
||||
|
||||
// If original is smaller than max height or preset is 'original', use source dimensions
|
||||
if (sourceHeight <= maxHeight || sizePreset === 'original') {
|
||||
if (sourceHeight <= maxHeight || sizePreset === "original") {
|
||||
return { width: sourceWidth, height: sourceHeight };
|
||||
}
|
||||
|
||||
@@ -90,7 +107,9 @@ export class GifExporter {
|
||||
|
||||
// Initialize streaming decoder and load video metadata
|
||||
this.streamingDecoder = new StreamingVideoDecoder();
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(
|
||||
this.config.videoUrl,
|
||||
);
|
||||
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
@@ -118,6 +137,7 @@ export class GifExporter {
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
cursorSway: this.config.cursorSway,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
@@ -134,25 +154,33 @@ export class GifExporter {
|
||||
height: this.config.height,
|
||||
workerScript: GIF_WORKER_URL,
|
||||
repeat,
|
||||
background: '#000000',
|
||||
background: "#000000",
|
||||
transparent: null,
|
||||
dither: 'FloydSteinberg',
|
||||
dither: "FloydSteinberg",
|
||||
});
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
// Calculate frame delay in milliseconds (gif.js uses ms)
|
||||
const frameDelay = Math.round(1000 / this.config.frameRate);
|
||||
|
||||
console.log('[GifExporter] Original duration:', videoInfo.duration, 's');
|
||||
console.log('[GifExporter] Effective duration:', effectiveDuration, 's');
|
||||
console.log('[GifExporter] Total frames to export:', totalFrames);
|
||||
console.log('[GifExporter] Frame rate:', this.config.frameRate, 'FPS');
|
||||
console.log('[GifExporter] Frame delay:', frameDelay, 'ms');
|
||||
console.log('[GifExporter] Loop:', this.config.loop ? 'infinite' : 'once');
|
||||
console.log('[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)');
|
||||
console.log("[GifExporter] Original duration:", videoInfo.duration, "s");
|
||||
console.log("[GifExporter] Effective duration:", effectiveDuration, "s");
|
||||
console.log("[GifExporter] Total frames to export:", totalFrames);
|
||||
console.log("[GifExporter] Frame rate:", this.config.frameRate, "FPS");
|
||||
console.log("[GifExporter] Frame delay:", frameDelay, "ms");
|
||||
console.log(
|
||||
"[GifExporter] Loop:",
|
||||
this.config.loop ? "infinite" : "once",
|
||||
);
|
||||
console.log(
|
||||
"[GifExporter] Using streaming decode (web-demuxer + VideoDecoder)",
|
||||
);
|
||||
|
||||
let frameIndex = 0;
|
||||
|
||||
@@ -174,11 +202,11 @@ export class GifExporter {
|
||||
this.addRenderedGifFrame(frameDelay);
|
||||
frameIndex++;
|
||||
this.reportProgress(frameIndex, totalFrames);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
return { success: false, error: "Export cancelled" };
|
||||
}
|
||||
|
||||
// Update progress to show we're now in the finalizing phase
|
||||
@@ -188,25 +216,25 @@ export class GifExporter {
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
phase: "finalizing",
|
||||
});
|
||||
}
|
||||
|
||||
// Render the GIF
|
||||
const blob = await new Promise<Blob>((resolve, _reject) => {
|
||||
this.gif!.on('finished', (blob: Blob) => {
|
||||
this.gif!.on("finished", (blob: Blob) => {
|
||||
resolve(blob);
|
||||
});
|
||||
|
||||
// Track rendering progress
|
||||
this.gif!.on('progress', (progress: number) => {
|
||||
this.gif!.on("progress", (progress: number) => {
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame: totalFrames,
|
||||
totalFrames,
|
||||
percentage: 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
phase: 'finalizing',
|
||||
phase: "finalizing",
|
||||
renderProgress: Math.round(progress * 100),
|
||||
});
|
||||
}
|
||||
@@ -218,7 +246,7 @@ export class GifExporter {
|
||||
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
console.error('GIF Export error:', error);
|
||||
console.error("GIF Export error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
@@ -260,7 +288,7 @@ export class GifExporter {
|
||||
try {
|
||||
this.streamingDecoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying streaming decoder:', e);
|
||||
console.warn("Error destroying streaming decoder:", e);
|
||||
}
|
||||
this.streamingDecoder = null;
|
||||
}
|
||||
@@ -269,7 +297,7 @@ export class GifExporter {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying renderer:', e);
|
||||
console.warn("Error destroying renderer:", e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
@@ -277,4 +305,3 @@ export class GifExporter {
|
||||
this.gif = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+391
-363
@@ -1,416 +1,444 @@
|
||||
import type { ExportConfig, ExportProgress, ExportResult } from './types';
|
||||
import { AudioProcessor } from './audioEncoder';
|
||||
import { StreamingVideoDecoder } from './streamingDecoder';
|
||||
import { FrameRenderer } from './frameRenderer';
|
||||
import { VideoMuxer } from './muxer';
|
||||
import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, AudioRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
|
||||
import type {
|
||||
AnnotationRegion,
|
||||
AudioRegion,
|
||||
CropRegion,
|
||||
CursorTelemetryPoint,
|
||||
SpeedRegion,
|
||||
TrimRegion,
|
||||
ZoomRegion,
|
||||
} from "@/components/video-editor/types";
|
||||
import { AudioProcessor } from "./audioEncoder";
|
||||
import { FrameRenderer } from "./frameRenderer";
|
||||
import { VideoMuxer } from "./muxer";
|
||||
import { StreamingVideoDecoder } from "./streamingDecoder";
|
||||
import type { ExportConfig, ExportProgress, ExportResult } from "./types";
|
||||
|
||||
interface VideoExporterConfig extends ExportConfig {
|
||||
videoUrl: string;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
connectZooms?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
showCursor?: boolean;
|
||||
cursorSize?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
audioRegions?: AudioRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
videoUrl: string;
|
||||
wallpaper: string;
|
||||
zoomRegions: ZoomRegion[];
|
||||
trimRegions?: TrimRegion[];
|
||||
speedRegions?: SpeedRegion[];
|
||||
showShadow: boolean;
|
||||
shadowIntensity: number;
|
||||
backgroundBlur: number;
|
||||
zoomMotionBlur?: number;
|
||||
connectZooms?: boolean;
|
||||
borderRadius?: number;
|
||||
padding?: number;
|
||||
videoPadding?: number;
|
||||
cropRegion: CropRegion;
|
||||
annotationRegions?: AnnotationRegion[];
|
||||
cursorTelemetry?: CursorTelemetryPoint[];
|
||||
showCursor?: boolean;
|
||||
cursorSize?: number;
|
||||
cursorSmoothing?: number;
|
||||
cursorMotionBlur?: number;
|
||||
cursorClickBounce?: number;
|
||||
cursorSway?: number;
|
||||
audioRegions?: AudioRegion[];
|
||||
previewWidth?: number;
|
||||
previewHeight?: number;
|
||||
onProgress?: (progress: ExportProgress) => void;
|
||||
}
|
||||
|
||||
export class VideoExporter {
|
||||
private config: VideoExporterConfig;
|
||||
private streamingDecoder: StreamingVideoDecoder | null = null;
|
||||
private renderer: FrameRenderer | null = null;
|
||||
private encoder: VideoEncoder | null = null;
|
||||
private muxer: VideoMuxer | null = null;
|
||||
private audioProcessor: AudioProcessor | null = null;
|
||||
private cancelled = false;
|
||||
private encodeQueue = 0;
|
||||
// Increased queue size for better throughput with hardware encoding
|
||||
private readonly MAX_ENCODE_QUEUE = 120;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||
private pendingMuxing: Promise<void> = Promise.resolve();
|
||||
private chunkCount = 0;
|
||||
private readonly WINDOWS_FINALIZATION_TIMEOUT_MS = 60_000;
|
||||
private config: VideoExporterConfig;
|
||||
private streamingDecoder: StreamingVideoDecoder | null = null;
|
||||
private renderer: FrameRenderer | null = null;
|
||||
private encoder: VideoEncoder | null = null;
|
||||
private muxer: VideoMuxer | null = null;
|
||||
private audioProcessor: AudioProcessor | null = null;
|
||||
private cancelled = false;
|
||||
private encodeQueue = 0;
|
||||
// Increased queue size for better throughput with hardware encoding
|
||||
private readonly MAX_ENCODE_QUEUE = 120;
|
||||
private videoDescription: Uint8Array | undefined;
|
||||
private videoColorSpace: VideoColorSpaceInit | undefined;
|
||||
private pendingMuxing: Promise<void> = Promise.resolve();
|
||||
private chunkCount = 0;
|
||||
private readonly WINDOWS_FINALIZATION_TIMEOUT_MS = 60_000;
|
||||
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
constructor(config: VideoExporterConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
async export(): Promise<ExportResult> {
|
||||
try {
|
||||
this.cleanup();
|
||||
this.cancelled = false;
|
||||
|
||||
// Initialize streaming decoder and load video metadata
|
||||
this.streamingDecoder = new StreamingVideoDecoder();
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||
// Initialize streaming decoder and load video metadata
|
||||
this.streamingDecoder = new StreamingVideoDecoder();
|
||||
const videoInfo = await this.streamingDecoder.loadMetadata(this.config.videoUrl);
|
||||
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
wallpaper: this.config.wallpaper,
|
||||
zoomRegions: this.config.zoomRegions,
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
backgroundBlur: this.config.backgroundBlur,
|
||||
zoomMotionBlur: this.config.zoomMotionBlur,
|
||||
connectZooms: this.config.connectZooms,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
showCursor: this.config.showCursor,
|
||||
cursorSize: this.config.cursorSize,
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
// Initialize frame renderer
|
||||
this.renderer = new FrameRenderer({
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
wallpaper: this.config.wallpaper,
|
||||
zoomRegions: this.config.zoomRegions,
|
||||
showShadow: this.config.showShadow,
|
||||
shadowIntensity: this.config.shadowIntensity,
|
||||
backgroundBlur: this.config.backgroundBlur,
|
||||
zoomMotionBlur: this.config.zoomMotionBlur,
|
||||
connectZooms: this.config.connectZooms,
|
||||
borderRadius: this.config.borderRadius,
|
||||
padding: this.config.padding,
|
||||
cropRegion: this.config.cropRegion,
|
||||
videoWidth: videoInfo.width,
|
||||
videoHeight: videoInfo.height,
|
||||
annotationRegions: this.config.annotationRegions,
|
||||
speedRegions: this.config.speedRegions,
|
||||
previewWidth: this.config.previewWidth,
|
||||
previewHeight: this.config.previewHeight,
|
||||
cursorTelemetry: this.config.cursorTelemetry,
|
||||
showCursor: this.config.showCursor,
|
||||
cursorSize: this.config.cursorSize,
|
||||
cursorSmoothing: this.config.cursorSmoothing,
|
||||
cursorMotionBlur: this.config.cursorMotionBlur,
|
||||
cursorClickBounce: this.config.cursorClickBounce,
|
||||
cursorSway: this.config.cursorSway,
|
||||
});
|
||||
await this.renderer.initialize();
|
||||
|
||||
// Initialize video encoder
|
||||
await this.initializeEncoder();
|
||||
// Initialize video encoder
|
||||
await this.initializeEncoder();
|
||||
|
||||
const hasAudioRegions = (this.config.audioRegions ?? []).length > 0;
|
||||
const hasAudio = videoInfo.hasAudio || hasAudioRegions;
|
||||
const hasAudioRegions = (this.config.audioRegions ?? []).length > 0;
|
||||
const hasAudio = videoInfo.hasAudio || hasAudioRegions;
|
||||
|
||||
// Initialize muxer
|
||||
this.muxer = new VideoMuxer(this.config, hasAudio);
|
||||
await this.muxer.initialize();
|
||||
// Initialize muxer
|
||||
this.muxer = new VideoMuxer(this.config, hasAudio);
|
||||
await this.muxer.initialize();
|
||||
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions, this.config.speedRegions);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
// Calculate effective duration and frame count (excluding trim regions)
|
||||
const effectiveDuration = this.streamingDecoder.getEffectiveDuration(
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
);
|
||||
const totalFrames = Math.ceil(effectiveDuration * this.config.frameRate);
|
||||
|
||||
console.log('[VideoExporter] Original duration:', videoInfo.duration, 's');
|
||||
console.log('[VideoExporter] Effective duration:', effectiveDuration, 's');
|
||||
console.log('[VideoExporter] Total frames to export:', totalFrames);
|
||||
console.log('[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)');
|
||||
console.log("[VideoExporter] Original duration:", videoInfo.duration, "s");
|
||||
console.log("[VideoExporter] Effective duration:", effectiveDuration, "s");
|
||||
console.log("[VideoExporter] Total frames to export:", totalFrames);
|
||||
console.log("[VideoExporter] Using streaming decode (web-demuxer + VideoDecoder)");
|
||||
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
const frameDuration = 1_000_000 / this.config.frameRate; // in microseconds
|
||||
let frameIndex = 0;
|
||||
|
||||
// Stream decode and process frames — no seeking!
|
||||
await this.streamingDecoder.decodeAll(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
||||
if (this.cancelled) {
|
||||
videoFrame.close();
|
||||
return;
|
||||
}
|
||||
// Stream decode and process frames — no seeking!
|
||||
await this.streamingDecoder.decodeAll(
|
||||
this.config.frameRate,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
|
||||
if (this.cancelled) {
|
||||
videoFrame.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const sourceTimestampUs = sourceTimestampMs * 1000;
|
||||
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
|
||||
videoFrame.close();
|
||||
const timestamp = frameIndex * frameDuration;
|
||||
const sourceTimestampUs = sourceTimestampMs * 1000;
|
||||
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
|
||||
videoFrame.close();
|
||||
|
||||
await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex);
|
||||
frameIndex++;
|
||||
this.reportProgress(frameIndex, totalFrames);
|
||||
}
|
||||
);
|
||||
await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex);
|
||||
frameIndex++;
|
||||
this.reportProgress(frameIndex, totalFrames);
|
||||
},
|
||||
);
|
||||
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: 'Export cancelled' };
|
||||
}
|
||||
if (this.cancelled) {
|
||||
return { success: false, error: "Export cancelled" };
|
||||
}
|
||||
|
||||
// Finalize encoding
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
await this.awaitWithWindowsTimeout(this.encoder.flush(), 'encoder flush');
|
||||
}
|
||||
// Finalize encoding
|
||||
if (this.encoder && this.encoder.state === "configured") {
|
||||
await this.awaitWithWindowsTimeout(this.encoder.flush(), "encoder flush");
|
||||
}
|
||||
|
||||
// Wait for queued muxing operations to complete
|
||||
await this.awaitWithWindowsTimeout(this.pendingMuxing, 'muxing queued video chunks');
|
||||
// Wait for queued muxing operations to complete
|
||||
await this.awaitWithWindowsTimeout(this.pendingMuxing, "muxing queued video chunks");
|
||||
|
||||
if (hasAudio && !this.cancelled) {
|
||||
const demuxer = this.streamingDecoder.getDemuxer();
|
||||
if (demuxer || hasAudioRegions) {
|
||||
this.audioProcessor = new AudioProcessor();
|
||||
await this.awaitWithWindowsTimeout(
|
||||
this.audioProcessor.process(
|
||||
demuxer!,
|
||||
this.muxer!,
|
||||
this.config.videoUrl,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
undefined,
|
||||
this.config.audioRegions,
|
||||
),
|
||||
'audio processing',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (hasAudio && !this.cancelled) {
|
||||
const demuxer = this.streamingDecoder.getDemuxer();
|
||||
if (demuxer || hasAudioRegions) {
|
||||
this.audioProcessor = new AudioProcessor();
|
||||
await this.awaitWithWindowsTimeout(
|
||||
this.audioProcessor.process(
|
||||
demuxer!,
|
||||
this.muxer!,
|
||||
this.config.videoUrl,
|
||||
this.config.trimRegions,
|
||||
this.config.speedRegions,
|
||||
undefined,
|
||||
this.config.audioRegions,
|
||||
),
|
||||
"audio processing",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize muxer and get output blob
|
||||
const blob = await this.awaitWithWindowsTimeout(this.muxer!.finalize(), 'muxer finalization');
|
||||
// Finalize muxer and get output blob
|
||||
const blob = await this.awaitWithWindowsTimeout(this.muxer!.finalize(), "muxer finalization");
|
||||
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
return { success: true, blob };
|
||||
} catch (error) {
|
||||
console.error("Export error:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private isWindowsPlatform(): boolean {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return /Win/i.test(navigator.platform);
|
||||
}
|
||||
private isWindowsPlatform(): boolean {
|
||||
if (typeof navigator === "undefined") {
|
||||
return false;
|
||||
}
|
||||
return /Win/i.test(navigator.platform);
|
||||
}
|
||||
|
||||
private async awaitWithWindowsTimeout<T>(promise: Promise<T>, stage: string): Promise<T> {
|
||||
if (!this.isWindowsPlatform()) {
|
||||
return promise;
|
||||
}
|
||||
private async awaitWithWindowsTimeout<T>(promise: Promise<T>, stage: string): Promise<T> {
|
||||
if (!this.isWindowsPlatform()) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Export timed out during ${stage} on Windows`));
|
||||
}, this.WINDOWS_FINALIZATION_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error(`Export timed out during ${stage} on Windows`));
|
||||
}, this.WINDOWS_FINALIZATION_TIMEOUT_MS);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async encodeRenderedFrame(timestamp: number, frameDuration: number, frameIndex: number) {
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
private async encodeRenderedFrame(timestamp: number, frameDuration: number, frameIndex: number) {
|
||||
const canvas = this.renderer!.getCanvas();
|
||||
|
||||
// @ts-ignore - colorSpace not in TypeScript definitions but works at runtime
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
// @ts-expect-error - colorSpace not in TypeScript definitions but works at runtime
|
||||
const exportFrame = new VideoFrame(canvas, {
|
||||
timestamp,
|
||||
duration: frameDuration,
|
||||
colorSpace: {
|
||||
primaries: "bt709",
|
||||
transfer: "iec61966-2-1",
|
||||
matrix: "rgb",
|
||||
fullRange: true,
|
||||
},
|
||||
});
|
||||
|
||||
while (this.encoder && this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
}
|
||||
while (
|
||||
this.encoder &&
|
||||
this.encoder.encodeQueueSize >= this.MAX_ENCODE_QUEUE &&
|
||||
!this.cancelled
|
||||
) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
}
|
||||
|
||||
if (this.encoder && this.encoder.state === 'configured') {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
|
||||
} else {
|
||||
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
|
||||
}
|
||||
if (this.encoder && this.encoder.state === "configured") {
|
||||
this.encodeQueue++;
|
||||
this.encoder.encode(exportFrame, { keyFrame: frameIndex % 150 === 0 });
|
||||
} else {
|
||||
console.warn(`[Frame ${frameIndex}] Encoder not ready! State: ${this.encoder?.state}`);
|
||||
}
|
||||
|
||||
exportFrame.close();
|
||||
}
|
||||
exportFrame.close();
|
||||
}
|
||||
|
||||
private reportProgress(currentFrame: number, totalFrames: number) {
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame,
|
||||
totalFrames,
|
||||
percentage: totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
private reportProgress(currentFrame: number, totalFrames: number) {
|
||||
if (this.config.onProgress) {
|
||||
this.config.onProgress({
|
||||
currentFrame,
|
||||
totalFrames,
|
||||
percentage: totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 100,
|
||||
estimatedTimeRemaining: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async initializeEncoder(): Promise<void> {
|
||||
this.encodeQueue = 0;
|
||||
this.pendingMuxing = Promise.resolve();
|
||||
this.chunkCount = 0;
|
||||
let videoDescription: Uint8Array | undefined;
|
||||
private async initializeEncoder(): Promise<void> {
|
||||
this.encodeQueue = 0;
|
||||
this.pendingMuxing = Promise.resolve();
|
||||
this.chunkCount = 0;
|
||||
let videoDescription: Uint8Array | undefined;
|
||||
|
||||
// Ordered from most capable to most compatible. avc1.PPCCLL where PP=profile, CC=constraints, LL=level.
|
||||
// High 5.1 → Main 5.1 → Baseline 5.1 → Main 3.1 → Baseline 3.1
|
||||
const CODEC_FALLBACK_LIST = this.config.codec
|
||||
? [this.config.codec]
|
||||
: ['avc1.640033', 'avc1.4d4033', 'avc1.420033', 'avc1.4d401f', 'avc1.42001f'];
|
||||
// Ordered from most capable to most compatible. avc1.PPCCLL where PP=profile, CC=constraints, LL=level.
|
||||
// High 5.1 → Main 5.1 → Baseline 5.1 → Main 3.1 → Baseline 3.1
|
||||
const CODEC_FALLBACK_LIST = this.config.codec
|
||||
? [this.config.codec]
|
||||
: ["avc1.640033", "avc1.4d4033", "avc1.420033", "avc1.4d401f", "avc1.42001f"];
|
||||
|
||||
let resolvedCodec: string | null = null;
|
||||
let resolvedCodec: string | null = null;
|
||||
|
||||
this.encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
// Capture decoder config metadata from encoder output
|
||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||
const desc = meta.decoderConfig.description;
|
||||
videoDescription = new Uint8Array(desc instanceof ArrayBuffer ? desc : (desc as any));
|
||||
this.videoDescription = videoDescription;
|
||||
}
|
||||
// Capture colorSpace from encoder metadata if provided
|
||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||
}
|
||||
this.encoder = new VideoEncoder({
|
||||
output: (chunk, meta) => {
|
||||
// Capture decoder config metadata from encoder output
|
||||
if (meta?.decoderConfig?.description && !videoDescription) {
|
||||
const desc = meta.decoderConfig.description;
|
||||
videoDescription = ArrayBuffer.isView(desc)
|
||||
? new Uint8Array(desc.buffer, desc.byteOffset, desc.byteLength)
|
||||
: new Uint8Array(desc);
|
||||
this.videoDescription = videoDescription;
|
||||
}
|
||||
// Capture colorSpace from encoder metadata if provided
|
||||
if (meta?.decoderConfig?.colorSpace && !this.videoColorSpace) {
|
||||
this.videoColorSpace = meta.decoderConfig.colorSpace;
|
||||
}
|
||||
|
||||
// Stream chunks to muxer in order without retaining an ever-growing promise array
|
||||
const isFirstChunk = this.chunkCount === 0;
|
||||
this.chunkCount++;
|
||||
// Stream chunks to muxer in order without retaining an ever-growing promise array
|
||||
const isFirstChunk = this.chunkCount === 0;
|
||||
this.chunkCount++;
|
||||
|
||||
this.pendingMuxing = this.pendingMuxing.then(async () => {
|
||||
try {
|
||||
if (isFirstChunk && this.videoDescription) {
|
||||
// Add decoder config for the first chunk
|
||||
const colorSpace = this.videoColorSpace || {
|
||||
primaries: 'bt709',
|
||||
transfer: 'iec61966-2-1',
|
||||
matrix: 'rgb',
|
||||
fullRange: true,
|
||||
};
|
||||
this.pendingMuxing = this.pendingMuxing.then(async () => {
|
||||
try {
|
||||
if (isFirstChunk && this.videoDescription) {
|
||||
// Add decoder config for the first chunk
|
||||
const colorSpace = this.videoColorSpace || {
|
||||
primaries: "bt709",
|
||||
transfer: "iec61966-2-1",
|
||||
matrix: "rgb",
|
||||
fullRange: true,
|
||||
};
|
||||
|
||||
const metadata: EncodedVideoChunkMetadata = {
|
||||
decoderConfig: {
|
||||
codec: resolvedCodec ?? (this.config.codec || 'avc1.640033'),
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
colorSpace,
|
||||
},
|
||||
};
|
||||
const metadata: EncodedVideoChunkMetadata = {
|
||||
decoderConfig: {
|
||||
codec: resolvedCodec ?? (this.config.codec || "avc1.640033"),
|
||||
codedWidth: this.config.width,
|
||||
codedHeight: this.config.height,
|
||||
description: this.videoDescription,
|
||||
colorSpace,
|
||||
},
|
||||
};
|
||||
|
||||
await this.muxer!.addVideoChunk(chunk, metadata);
|
||||
} else {
|
||||
await this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Muxing error:', error);
|
||||
}
|
||||
});
|
||||
this.encodeQueue--;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(
|
||||
`[VideoExporter] Encoder error (codec: ${resolvedCodec}, ${this.config.width}x${this.config.height}):`,
|
||||
error,
|
||||
);
|
||||
// Stop export — encoding failed
|
||||
this.cancelled = true;
|
||||
},
|
||||
});
|
||||
await this.muxer!.addVideoChunk(chunk, metadata);
|
||||
} else {
|
||||
await this.muxer!.addVideoChunk(chunk, meta);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Muxing error:", error);
|
||||
}
|
||||
});
|
||||
this.encodeQueue--;
|
||||
},
|
||||
error: (error) => {
|
||||
console.error(
|
||||
`[VideoExporter] Encoder error (codec: ${resolvedCodec}, ${this.config.width}x${this.config.height}):`,
|
||||
error,
|
||||
);
|
||||
// Stop export — encoding failed
|
||||
this.cancelled = true;
|
||||
},
|
||||
});
|
||||
|
||||
const baseConfig: Omit<VideoEncoderConfig, 'codec' | 'hardwareAcceleration'> = {
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: 'quality',
|
||||
bitrateMode: 'variable',
|
||||
};
|
||||
const baseConfig: Omit<VideoEncoderConfig, "codec" | "hardwareAcceleration"> = {
|
||||
width: this.config.width,
|
||||
height: this.config.height,
|
||||
bitrate: this.config.bitrate,
|
||||
framerate: this.config.frameRate,
|
||||
latencyMode: "quality",
|
||||
bitrateMode: "variable",
|
||||
};
|
||||
|
||||
for (const candidateCodec of CODEC_FALLBACK_LIST) {
|
||||
const hwConfig: VideoEncoderConfig = { ...baseConfig, codec: candidateCodec, hardwareAcceleration: 'prefer-hardware' };
|
||||
const hwSupport = await VideoEncoder.isConfigSupported(hwConfig);
|
||||
if (hwSupport.supported) {
|
||||
resolvedCodec = candidateCodec;
|
||||
console.log(`[VideoExporter] Using hardware acceleration with codec ${candidateCodec}`);
|
||||
this.encoder.configure(hwConfig);
|
||||
return;
|
||||
}
|
||||
for (const candidateCodec of CODEC_FALLBACK_LIST) {
|
||||
const hwConfig: VideoEncoderConfig = {
|
||||
...baseConfig,
|
||||
codec: candidateCodec,
|
||||
hardwareAcceleration: "prefer-hardware",
|
||||
};
|
||||
const hwSupport = await VideoEncoder.isConfigSupported(hwConfig);
|
||||
if (hwSupport.supported) {
|
||||
resolvedCodec = candidateCodec;
|
||||
console.log(`[VideoExporter] Using hardware acceleration with codec ${candidateCodec}`);
|
||||
this.encoder.configure(hwConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
const swConfig: VideoEncoderConfig = { ...baseConfig, codec: candidateCodec, hardwareAcceleration: 'prefer-software' };
|
||||
const swSupport = await VideoEncoder.isConfigSupported(swConfig);
|
||||
if (swSupport.supported) {
|
||||
resolvedCodec = candidateCodec;
|
||||
console.log(`[VideoExporter] Using software encoding with codec ${candidateCodec}`);
|
||||
this.encoder.configure(swConfig);
|
||||
return;
|
||||
}
|
||||
const swConfig: VideoEncoderConfig = {
|
||||
...baseConfig,
|
||||
codec: candidateCodec,
|
||||
hardwareAcceleration: "prefer-software",
|
||||
};
|
||||
const swSupport = await VideoEncoder.isConfigSupported(swConfig);
|
||||
if (swSupport.supported) {
|
||||
resolvedCodec = candidateCodec;
|
||||
console.log(`[VideoExporter] Using software encoding with codec ${candidateCodec}`);
|
||||
this.encoder.configure(swConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`[VideoExporter] Codec ${candidateCodec} not supported (${this.config.width}x${this.config.height}), trying next…`);
|
||||
}
|
||||
console.warn(
|
||||
`[VideoExporter] Codec ${candidateCodec} not supported (${this.config.width}x${this.config.height}), trying next...`,
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Video encoding not supported on this system. ` +
|
||||
`Tried codecs: ${CODEC_FALLBACK_LIST.join(', ')} at ${this.config.width}x${this.config.height}. ` +
|
||||
`Your browser or hardware may not support H.264 encoding at this resolution. ` +
|
||||
`Try exporting at a lower quality setting.`,
|
||||
);
|
||||
}
|
||||
throw new Error(
|
||||
`Video encoding not supported on this system. ` +
|
||||
`Tried codecs: ${CODEC_FALLBACK_LIST.join(", ")} at ${this.config.width}x${this.config.height}. ` +
|
||||
`Your browser or hardware may not support H.264 encoding at this resolution. ` +
|
||||
`Try exporting at a lower quality setting.`,
|
||||
);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
if (this.streamingDecoder) {
|
||||
this.streamingDecoder.cancel();
|
||||
}
|
||||
if (this.audioProcessor) {
|
||||
this.audioProcessor.cancel();
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
cancel(): void {
|
||||
this.cancelled = true;
|
||||
if (this.streamingDecoder) {
|
||||
this.streamingDecoder.cancel();
|
||||
}
|
||||
if (this.audioProcessor) {
|
||||
this.audioProcessor.cancel();
|
||||
}
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
if (this.encoder) {
|
||||
try {
|
||||
if (this.encoder.state === 'configured') {
|
||||
this.encoder.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Error closing encoder:', e);
|
||||
}
|
||||
this.encoder = null;
|
||||
}
|
||||
private cleanup(): void {
|
||||
if (this.encoder) {
|
||||
try {
|
||||
if (this.encoder.state === "configured") {
|
||||
this.encoder.close();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error closing encoder:", e);
|
||||
}
|
||||
this.encoder = null;
|
||||
}
|
||||
|
||||
if (this.streamingDecoder) {
|
||||
try {
|
||||
this.streamingDecoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying streaming decoder:', e);
|
||||
}
|
||||
this.streamingDecoder = null;
|
||||
}
|
||||
if (this.streamingDecoder) {
|
||||
try {
|
||||
this.streamingDecoder.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying streaming decoder:", e);
|
||||
}
|
||||
this.streamingDecoder = null;
|
||||
}
|
||||
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('Error destroying renderer:', e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
if (this.renderer) {
|
||||
try {
|
||||
this.renderer.destroy();
|
||||
} catch (e) {
|
||||
console.warn("Error destroying renderer:", e);
|
||||
}
|
||||
this.renderer = null;
|
||||
}
|
||||
|
||||
this.muxer = null;
|
||||
this.audioProcessor = null;
|
||||
this.encodeQueue = 0;
|
||||
this.pendingMuxing = Promise.resolve();
|
||||
this.chunkCount = 0;
|
||||
this.videoDescription = undefined;
|
||||
this.videoColorSpace = undefined;
|
||||
}
|
||||
this.muxer = null;
|
||||
this.audioProcessor = null;
|
||||
this.encodeQueue = 0;
|
||||
this.pendingMuxing = Promise.resolve();
|
||||
this.chunkCount = 0;
|
||||
this.videoDescription = undefined;
|
||||
this.videoColorSpace = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+2
-151
@@ -1,151 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="../electron/electron-env" />
|
||||
|
||||
interface ProcessedDesktopSource {
|
||||
id: string;
|
||||
name: string;
|
||||
display_id: string;
|
||||
thumbnail: string | null;
|
||||
appIcon: string | null;
|
||||
originalName?: string;
|
||||
sourceType?: 'screen' | 'window';
|
||||
appName?: string;
|
||||
windowTitle?: string;
|
||||
}
|
||||
|
||||
interface CursorTelemetryPoint {
|
||||
timeMs: number;
|
||||
cx: number;
|
||||
cy: number;
|
||||
interactionType?: 'move' | 'click' | 'double-click' | 'right-click' | 'middle-click' | 'mouseup';
|
||||
cursorType?: 'arrow' | 'text' | 'pointer' | 'crosshair' | 'open-hand' | 'closed-hand' | 'resize-ew' | 'resize-ns' | 'not-allowed';
|
||||
}
|
||||
|
||||
interface SystemCursorAsset {
|
||||
dataUrl: string;
|
||||
hotspotX: number;
|
||||
hotspotY: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
electronAPI: {
|
||||
getSources: (opts: Electron.SourcesOptions) => Promise<ProcessedDesktopSource[]>
|
||||
switchToEditor: () => Promise<void>
|
||||
openSourceSelector: () => Promise<void>
|
||||
selectSource: (source: any) => Promise<any>
|
||||
getSelectedSource: () => Promise<any>
|
||||
startNativeScreenRecording: (source: any, options?: {
|
||||
capturesSystemAudio?: boolean
|
||||
capturesMicrophone?: boolean
|
||||
microphoneDeviceId?: string
|
||||
microphoneLabel?: string
|
||||
}) => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
stopNativeScreenRecording: () => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
startFfmpegRecording: (source: any) => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
stopFfmpegRecording: () => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
storeRecordedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message: string
|
||||
error?: string
|
||||
}>
|
||||
getRecordedVideoPath: () => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
readLocalFile: (filePath: string) => Promise<{
|
||||
success: boolean
|
||||
data?: Uint8Array
|
||||
error?: string
|
||||
}>
|
||||
getAssetBasePath: () => Promise<string | null>
|
||||
setRecordingState: (recording: boolean) => Promise<void>
|
||||
getCursorTelemetry: (videoPath?: string) => Promise<{
|
||||
success: boolean
|
||||
samples: CursorTelemetryPoint[]
|
||||
message?: string
|
||||
error?: string
|
||||
}>
|
||||
getSystemCursorAssets: () => Promise<{
|
||||
success: boolean
|
||||
cursors: Record<string, SystemCursorAsset>
|
||||
error?: string
|
||||
}>
|
||||
onStopRecordingFromTray: (callback: () => void) => () => void
|
||||
onRecordingStateChanged: (callback: (state: { recording: boolean; sourceName: string }) => void) => () => void
|
||||
onRecordingInterrupted: (callback: (state: { reason: string; message: string }) => void) => () => void
|
||||
onCursorStateChanged: (callback: (state: { cursorType: CursorTelemetryPoint['cursorType'] }) => void) => () => void
|
||||
openExternalUrl: (url: string) => Promise<{ success: boolean; error?: string }>
|
||||
getAccessibilityPermissionStatus: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }>
|
||||
requestAccessibilityPermission: () => Promise<{ success: boolean; trusted: boolean; prompted: boolean; error?: string }>
|
||||
getScreenRecordingPermissionStatus: () => Promise<{ success: boolean; status: string; error?: string }>
|
||||
openScreenRecordingPreferences: () => Promise<{ success: boolean; error?: string }>
|
||||
openAccessibilityPreferences: () => Promise<{ success: boolean; error?: string }>
|
||||
saveExportedVideo: (videoData: ArrayBuffer, fileName: string) => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
canceled?: boolean
|
||||
}>
|
||||
openVideoFilePicker: () => Promise<{ success: boolean; path?: string; canceled?: boolean }>
|
||||
setCurrentVideoPath: (path: string) => Promise<{ success: boolean }>
|
||||
getCurrentVideoPath: () => Promise<{ success: boolean; path?: string }>
|
||||
clearCurrentVideoPath: () => Promise<{ success: boolean }>
|
||||
saveProjectFile: (projectData: unknown, suggestedName?: string, existingProjectPath?: string) => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
message?: string
|
||||
canceled?: boolean
|
||||
error?: string
|
||||
}>
|
||||
loadProjectFile: () => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
project?: unknown
|
||||
message?: string
|
||||
canceled?: boolean
|
||||
error?: string
|
||||
}>
|
||||
loadCurrentProjectFile: () => Promise<{
|
||||
success: boolean
|
||||
path?: string
|
||||
project?: unknown
|
||||
message?: string
|
||||
canceled?: boolean
|
||||
error?: string
|
||||
}>
|
||||
onMenuLoadProject: (callback: () => void) => () => void
|
||||
onMenuSaveProject: (callback: () => void) => () => void
|
||||
onMenuSaveProjectAs: (callback: () => void) => () => void
|
||||
hideOsCursor: () => Promise<{ success: boolean }>
|
||||
setHasUnsavedChanges: (hasChanges: boolean) => void
|
||||
onRequestSaveBeforeClose: (callback: () => Promise<void>) => () => void
|
||||
getRecordingsDirectory: () => Promise<{ success: boolean; path: string; isDefault: boolean; error?: string }>
|
||||
chooseRecordingsDirectory: () => Promise<{ success: boolean; canceled?: boolean; path?: string; isDefault?: boolean; message?: string; error?: string }>
|
||||
}
|
||||
}
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="../electron/electron-env" />
|
||||
|
||||
Reference in New Issue
Block a user