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:
webadderall
2026-03-17 10:24:00 +11:00
committed by GitHub
30 changed files with 7675 additions and 5920 deletions
+227 -122
View File
@@ -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;
}
+211 -198
View File
@@ -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
View File
@@ -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
View File
@@ -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>
);
}
}
+457 -344
View File
@@ -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
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.
}
}
+365 -330
View File
@@ -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);
+51 -26
View File
@@ -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;
}
+2
View File
@@ -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"
},
+3 -2
View File
@@ -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",
+2
View File
@@ -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"
},
+3 -2
View File
@@ -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",
+2
View File
@@ -12,6 +12,8 @@
"folderPath": "路径:/{{name}}/",
"openVideoFile": "打开视频文件",
"openProject": "打开项目",
"hideHudFromVideo": "在录制中隐藏 HUD",
"showHudInVideo": "在录制中显示 HUD",
"hideHud": "隐藏 HUD",
"closeApp": "关闭应用"
},
+1
View File
@@ -24,6 +24,7 @@
"off": "关",
"cursorMotionBlur": "光标运动模糊",
"cursorClickBounce": "光标点击弹跳",
"cursorSway": "光标摆动",
"shadow": "阴影",
"roundness": "圆角",
"padding": "内边距"
+235 -112
View File
@@ -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;
}
}
+56 -29
View File
@@ -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
View File
@@ -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;
}
}
+2 -151
View File
@@ -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" />