-
Openscreen
+
+
+
+
+ );
+ default:
+ return (
+
+
+
+
+
Recordly
+
Screen recording and editing
+
+
);
}
diff --git a/src/assets/cursors/Cursor=Beachball.svg b/src/assets/cursors/Cursor=Beachball.svg
new file mode 100644
index 00000000..08eee952
--- /dev/null
+++ b/src/assets/cursors/Cursor=Beachball.svg
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Cross.svg b/src/assets/cursors/Cursor=Cross.svg
new file mode 100644
index 00000000..803171ea
--- /dev/null
+++ b/src/assets/cursors/Cursor=Cross.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Default.svg b/src/assets/cursors/Cursor=Default.svg
new file mode 100644
index 00000000..18b29be7
--- /dev/null
+++ b/src/assets/cursors/Cursor=Default.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Hand-(Grabbing).svg b/src/assets/cursors/Cursor=Hand-(Grabbing).svg
new file mode 100644
index 00000000..4b76948b
--- /dev/null
+++ b/src/assets/cursors/Cursor=Hand-(Grabbing).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Hand-(Open).svg b/src/assets/cursors/Cursor=Hand-(Open).svg
new file mode 100644
index 00000000..ea0a83ab
--- /dev/null
+++ b/src/assets/cursors/Cursor=Hand-(Open).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Hand-(Pointing).svg b/src/assets/cursors/Cursor=Hand-(Pointing).svg
new file mode 100644
index 00000000..a8000ee1
--- /dev/null
+++ b/src/assets/cursors/Cursor=Hand-(Pointing).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Menu.svg b/src/assets/cursors/Cursor=Menu.svg
new file mode 100644
index 00000000..47bc6bb6
--- /dev/null
+++ b/src/assets/cursors/Cursor=Menu.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Move.svg b/src/assets/cursors/Cursor=Move.svg
new file mode 100644
index 00000000..fc766e63
--- /dev/null
+++ b/src/assets/cursors/Cursor=Move.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Down).svg b/src/assets/cursors/Cursor=Resize-(Down).svg
new file mode 100644
index 00000000..ebb5a054
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Down).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Left).svg b/src/assets/cursors/Cursor=Resize-(Left).svg
new file mode 100644
index 00000000..d5c6a82c
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Left).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Left-Right).svg b/src/assets/cursors/Cursor=Resize-(Left-Right).svg
new file mode 100644
index 00000000..2e0371f9
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Left-Right).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Right).svg b/src/assets/cursors/Cursor=Resize-(Right).svg
new file mode 100644
index 00000000..54dd100d
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Right).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Up).svg b/src/assets/cursors/Cursor=Resize-(Up).svg
new file mode 100644
index 00000000..22bb9583
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Up).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-(Up-Down).svg b/src/assets/cursors/Cursor=Resize-(Up-Down).svg
new file mode 100644
index 00000000..539e6b8c
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-(Up-Down).svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-North-East-South-West.svg b/src/assets/cursors/Cursor=Resize-North-East-South-West.svg
new file mode 100644
index 00000000..af753c2a
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-North-East-South-West.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-North-South.svg b/src/assets/cursors/Cursor=Resize-North-South.svg
new file mode 100644
index 00000000..b5db212d
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-North-South.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-North-West-South-East.svg b/src/assets/cursors/Cursor=Resize-North-West-South-East.svg
new file mode 100644
index 00000000..22353ee3
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-North-West-South-East.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Resize-West-East.svg b/src/assets/cursors/Cursor=Resize-West-East.svg
new file mode 100644
index 00000000..2c0231de
--- /dev/null
+++ b/src/assets/cursors/Cursor=Resize-West-East.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Text-Cursor.svg b/src/assets/cursors/Cursor=Text-Cursor.svg
new file mode 100644
index 00000000..ade24f4c
--- /dev/null
+++ b/src/assets/cursors/Cursor=Text-Cursor.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Zoom-In.svg b/src/assets/cursors/Cursor=Zoom-In.svg
new file mode 100644
index 00000000..41a87aec
--- /dev/null
+++ b/src/assets/cursors/Cursor=Zoom-In.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/assets/cursors/Cursor=Zoom-Out.svg b/src/assets/cursors/Cursor=Zoom-Out.svg
new file mode 100644
index 00000000..756ef22e
--- /dev/null
+++ b/src/assets/cursors/Cursor=Zoom-Out.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx
index 322e17b2..5d0678a6 100644
--- a/src/components/launch/LaunchWindow.tsx
+++ b/src/components/launch/LaunchWindow.tsx
@@ -1,19 +1,43 @@
-import { useState, useEffect } from "react";
-import styles from "./LaunchWindow.module.css";
-import { useScreenRecorder } from "../../hooks/useScreenRecorder";
-import { Button } from "../ui/button";
+import { useEffect, useState } from "react";
import { BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
-import { MdMonitor } from "react-icons/md";
-import { RxDragHandleDots2 } from "react-icons/rx";
-import { FaFolderMinus } from "react-icons/fa6";
+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 { RxDragHandleDots2 } from "react-icons/rx";
+import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
+import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
+import { useScreenRecorder } from "../../hooks/useScreenRecorder";
+import { Button } from "../ui/button";
+import { AudioLevelMeter } from "../ui/audio-level-meter";
import { ContentClamp } from "../ui/content-clamp";
+import styles from "./LaunchWindow.module.css";
export function LaunchWindow() {
- const { recording, toggleRecording } = useScreenRecorder();
+ const {
+ recording,
+ toggleRecording,
+ microphoneEnabled,
+ setMicrophoneEnabled,
+ microphoneDeviceId,
+ setMicrophoneDeviceId,
+ systemAudioEnabled,
+ setSystemAudioEnabled,
+ } = useScreenRecorder();
const [recordingStart, setRecordingStart] = useState
(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(() => {
let timer: NodeJS.Timeout | null = null;
@@ -35,10 +59,11 @@ export function LaunchWindow() {
}, [recording, recordingStart]);
const formatTime = (seconds: number) => {
- const m = Math.floor(seconds / 60).toString().padStart(2, '0');
- const s = (seconds % 60).toString().padStart(2, '0');
+ 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);
@@ -56,128 +81,189 @@ export function LaunchWindow() {
}
};
- checkSelectedSource();
-
+ void checkSelectedSource();
const interval = setInterval(checkSelectedSource, 500);
return () => clearInterval(interval);
}, []);
const openSourceSelector = () => {
- if (window.electronAPI) {
- window.electronAPI.openSourceSelector();
- }
+ window.electronAPI?.openSourceSelector();
};
const openVideoFile = async () => {
const result = await window.electronAPI.openVideoFilePicker();
-
- if (result.cancelled) {
+ if (result.canceled) {
return;
}
-
+
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
await window.electronAPI.switchToEditor();
}
};
- // IPC events for hide/close
- const sendHudOverlayHide = () => {
- if (window.electronAPI && window.electronAPI.hudOverlayHide) {
- window.electronAPI.hudOverlayHide();
+ const openProjectFile = async () => {
+ const result = await window.electronAPI.loadProjectFile();
+ if (result.canceled || !result.success) {
+ return;
}
+ await window.electronAPI.switchToEditor();
};
+
+ const sendHudOverlayHide = () => {
+ window.electronAPI?.hudOverlayHide?.();
+ };
+
const sendHudOverlayClose = () => {
- if (window.electronAPI && window.electronAPI.hudOverlayClose) {
- window.electronAPI.hudOverlayClose();
+ window.electronAPI?.hudOverlayClose?.();
+ };
+
+ const toggleMicrophone = () => {
+ if (!recording) {
+ setMicrophoneEnabled(!microphoneEnabled);
}
};
return (
-
-
-
+
+
+ {showMicControls && (
+
+
{
+ setSelectedDeviceId(event.target.value);
+ setMicrophoneDeviceId(event.target.value);
+ }}
+ className="max-w-[230px] rounded-full border border-white/15 bg-white/10 px-3 py-1 text-xs text-white outline-none"
+ >
+ {devices.map((device) => (
+
+ {device.label}
+
+ ))}
+
+
+
+ )}
-
-
- {selectedSource}
-
+
+
+
-
+
+
+ {selectedSource}
+
-
- {recording ? (
- <>
-
- {formatTime(elapsed)}
- >
- ) : (
- <>
-
- Record
- >
- )}
-
-
+
-
+
+ !recording && setSystemAudioEnabled(!systemAudioEnabled)}
+ disabled={recording}
+ title={systemAudioEnabled ? "Disable system audio" : "Enable system audio"}
+ className="text-white/80 hover:bg-transparent"
+ >
+ {systemAudioEnabled ? : }
+
+
+ {microphoneEnabled ? : }
+
+
+
-
-
- Open
-
+
+ {recording ? (
+ <>
+
+ {formatTime(elapsed)}
+ >
+ ) : (
+ <>
+
+ Record
+ >
+ )}
+
- {/* Separator before hide/close buttons */}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/components/launch/SourceSelector.module.css b/src/components/launch/SourceSelector.module.css
index 9430b993..302cffe5 100644
--- a/src/components/launch/SourceSelector.module.css
+++ b/src/components/launch/SourceSelector.module.css
@@ -17,9 +17,9 @@
}
.selected {
- border: 2px solid #34B27B;
+ border: 2px solid #2563EB;
background: linear-gradient(120deg, rgba(91,33,182,0.18) 0%, rgba(38,38,48,0.98) 100%);
- box-shadow: 0 0 0 2px #34B27B33;
+ box-shadow: 0 0 0 2px #2563EB33;
}
.icon {
@@ -43,7 +43,7 @@
/* scrollbar */
.sourceGridScroll {
scrollbar-width: thin;
- scrollbar-color: rgba(52, 178, 123, 0.5) rgba(40, 40, 50, 0.6);
+ scrollbar-color: rgba(37, 99, 235, 0.5) rgba(40, 40, 50, 0.6);
}
.sourceGridScroll::-webkit-scrollbar {
@@ -62,9 +62,9 @@
}
.sourceGridScroll::-webkit-scrollbar-thumb:hover {
- background: rgba(52, 178, 123, 0.6);
+ background: rgba(37, 99, 235, 0.6);
}
.sourceGridScroll::-webkit-scrollbar-thumb:active {
- background: rgba(52, 178, 123, 0.8);
+ background: rgba(37, 99, 235, 0.8);
}
diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx
index 33c67c1e..54ca4ad2 100644
--- a/src/components/launch/SourceSelector.tsx
+++ b/src/components/launch/SourceSelector.tsx
@@ -11,6 +11,42 @@ interface DesktopSource {
thumbnail: string | null;
display_id: string;
appIcon: string | null;
+ originalName: string;
+ sourceType: 'screen' | 'window';
+ appName?: string;
+ windowTitle?: string;
+}
+
+function parseSourceMetadata(source: ProcessedDesktopSource) {
+ if (source.sourceType === 'window' && (source.appName || source.windowTitle)) {
+ return {
+ sourceType: 'window' as const,
+ appName: source.appName,
+ windowTitle: source.windowTitle ?? source.name,
+ displayName: source.windowTitle ?? source.name,
+ };
+ }
+
+ const sourceType: 'screen' | 'window' = source.id.startsWith('window:') ? 'window' : 'screen';
+ if (sourceType === 'window') {
+ const [appNamePart, ...windowTitleParts] = source.name.split(' — ');
+ const appName = appNamePart?.trim() || undefined;
+ const windowTitle = windowTitleParts.join(' — ').trim() || source.name.trim();
+
+ return {
+ sourceType,
+ appName,
+ windowTitle,
+ displayName: windowTitle,
+ };
+ }
+
+ return {
+ sourceType,
+ appName: undefined,
+ windowTitle: undefined,
+ displayName: source.name,
+ };
}
export function SourceSelector() {
@@ -28,16 +64,21 @@ export function SourceSelector() {
fetchWindowIcons: true
});
setSources(
- rawSources.map(source => ({
- id: source.id,
- name:
- source.id.startsWith('window:') && source.name.includes(' — ')
- ? source.name.split(' — ')[1] || source.name
- : source.name,
- thumbnail: source.thumbnail,
- display_id: source.display_id,
- appIcon: source.appIcon
- }))
+ rawSources.map(source => {
+ const metadata = parseSourceMetadata(source);
+
+ return {
+ id: source.id,
+ name: metadata.displayName,
+ thumbnail: source.thumbnail,
+ display_id: source.display_id,
+ appIcon: source.appIcon,
+ originalName: source.name,
+ sourceType: metadata.sourceType,
+ appName: metadata.appName,
+ windowTitle: metadata.windowTitle,
+ };
+ })
);
} catch (error) {
console.error('Error loading sources:', error);
@@ -72,8 +113,8 @@ export function SourceSelector() {
- Screens
- Windows
+ Screens
+ Windows
@@ -94,7 +135,7 @@ export function SourceSelector() {
/>
{selectedSource?.id === source.id && (
-
@@ -107,6 +148,7 @@ export function SourceSelector() {
+ Only visible (non-minimized) windows can be recorded.
{windowSources.map(source => (
-
+ {source.thumbnail ? (
+
+ ) : (
+
+ {source.appIcon ? (
+
+ ) : (
+
+ )}
+
Window
+
+ )}
{selectedSource?.id === source.id && (
@@ -151,7 +208,7 @@ export function SourceSelector() {
window.close()} className="px-4 py-1 text-xs bg-zinc-800 border-zinc-700 text-zinc-200 hover:bg-zinc-700">Cancel
- Share
+ Share
diff --git a/src/components/ui/audio-level-meter.tsx b/src/components/ui/audio-level-meter.tsx
new file mode 100644
index 00000000..e2b08f80
--- /dev/null
+++ b/src/components/ui/audio-level-meter.tsx
@@ -0,0 +1,37 @@
+interface AudioLevelMeterProps {
+ level: number;
+ className?: string;
+}
+
+const bars = [
+ { threshold: 10, height: '30%' },
+ { threshold: 25, height: '45%' },
+ { threshold: 45, height: '60%' },
+ { threshold: 65, height: '75%' },
+ { threshold: 85, height: '90%' },
+]
+
+function getBarColor(level: number, threshold: number) {
+ if (!level || level < threshold) return 'bg-slate-700'
+ if (threshold > 80) return 'bg-red-500'
+ if (threshold > 60) return 'bg-yellow-500'
+ if (threshold > 40) return 'bg-blue-500'
+ return 'bg-blue-400'
+}
+
+export function AudioLevelMeter({ level, className = '' }: AudioLevelMeterProps) {
+ return (
+
+ {bars.map((bar, index) => (
+
= bar.threshold ? bar.height : '15%',
+ opacity: level >= bar.threshold ? 1 : 0.4,
+ }}
+ />
+ ))}
+
+ )
+}
\ No newline at end of file
diff --git a/src/components/ui/item-content.tsx b/src/components/ui/item-content.tsx
index ebaae9df..57415262 100644
--- a/src/components/ui/item-content.tsx
+++ b/src/components/ui/item-content.tsx
@@ -7,7 +7,7 @@ interface ItemContentProps extends PropsWithChildren {
function ItemContent({ children, classes }: ItemContentProps) {
return (
{children}
diff --git a/src/components/ui/slider.tsx b/src/components/ui/slider.tsx
index dcb8ef74..d0f6a729 100644
--- a/src/components/ui/slider.tsx
+++ b/src/components/ui/slider.tsx
@@ -16,9 +16,9 @@ const Slider = React.forwardRef<
{...props}
>
-
+
-
+
))
Slider.displayName = SliderPrimitive.Root.displayName
diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx
index 15859bb5..647058a8 100644
--- a/src/components/ui/switch.tsx
+++ b/src/components/ui/switch.tsx
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
{
const direction = annotation.figureData?.arrowDirection || 'right';
- const color = annotation.figureData?.color || '#34B27B';
+ const color = annotation.figureData?.color || '#2563EB';
const strokeWidth = annotation.figureData?.strokeWidth || 4;
const ArrowComponent = getArrowComponent(direction);
@@ -148,14 +148,14 @@ export function AnnotationOverlay({
bounds="parent"
className={cn(
"cursor-move transition-all",
- isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent"
+ isSelected && "ring-2 ring-[#2563EB] ring-offset-2 ring-offset-transparent"
)}
style={{
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
pointerEvents: isSelected ? 'auto' : 'none',
- border: isSelected ? '2px solid rgba(52, 178, 123, 0.8)' : 'none',
- backgroundColor: isSelected ? 'rgba(52, 178, 123, 0.1)' : 'transparent',
- boxShadow: isSelected ? '0 0 0 1px rgba(52, 178, 123, 0.35)' : 'none',
+ border: isSelected ? '2px solid rgba(37, 99, 235, 0.8)' : 'none',
+ backgroundColor: isSelected ? 'rgba(37, 99, 235, 0.1)' : 'transparent',
+ boxShadow: isSelected ? '0 0 0 1px rgba(37, 99, 235, 0.35)' : 'none',
}}
enableResizing={isSelected}
disableDragging={!isSelected}
@@ -164,7 +164,7 @@ export function AnnotationOverlay({
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
- border: isSelected ? '2px solid #34B27B' : 'none',
+ border: isSelected ? '2px solid #2563EB' : 'none',
borderRadius: '50%',
left: '-6px',
top: '-6px',
@@ -174,7 +174,7 @@ export function AnnotationOverlay({
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
- border: isSelected ? '2px solid #34B27B' : 'none',
+ border: isSelected ? '2px solid #2563EB' : 'none',
borderRadius: '50%',
right: '-6px',
top: '-6px',
@@ -184,7 +184,7 @@ export function AnnotationOverlay({
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
- border: isSelected ? '2px solid #34B27B' : 'none',
+ border: isSelected ? '2px solid #2563EB' : 'none',
borderRadius: '50%',
left: '-6px',
bottom: '-6px',
@@ -194,7 +194,7 @@ export function AnnotationOverlay({
width: '12px',
height: '12px',
backgroundColor: isSelected ? 'white' : 'transparent',
- border: isSelected ? '2px solid #34B27B' : 'none',
+ border: isSelected ? '2px solid #2563EB' : 'none',
borderRadius: '50%',
right: '-6px',
bottom: '-6px',
diff --git a/src/components/video-editor/AnnotationSettingsPanel.tsx b/src/components/video-editor/AnnotationSettingsPanel.tsx
index c89589f4..bfd4d4f6 100644
--- a/src/components/video-editor/AnnotationSettingsPanel.tsx
+++ b/src/components/video-editor/AnnotationSettingsPanel.tsx
@@ -65,7 +65,7 @@ export function AnnotationSettingsPanel({
'#FF5722', // Deep Orange
'#8BC34A', // Light Green
'#FFC107', // Amber
- '#34B27B', // Brand Green
+ '#2563EB', // Brand Blue
'#000000', // Black
'#607D8B', // Blue Grey
'#795548', // Brown
@@ -114,7 +114,7 @@ export function AnnotationSettingsPanel({
Annotation Settings
-
+
Active
@@ -122,15 +122,15 @@ export function AnnotationSettingsPanel({
{/* Type Selector */}
onTypeChange(value as AnnotationType)} className="mb-6">
-
+
Text
-
+
Image
-
+
@@ -147,7 +147,7 @@ export function AnnotationSettingsPanel({
onChange={(e) => onContentChange(e.target.value)}
placeholder="Enter your text..."
rows={5}
- className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#34B27B] focus:border-transparent resize-none"
+ className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-slate-200 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-[#2563EB] focus:border-transparent resize-none"
/>
@@ -227,7 +227,7 @@ export function AnnotationSettingsPanel({
aria-label="Toggle bold"
data-state={annotation.style.fontWeight === 'bold' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontWeight: annotation.style.fontWeight === 'bold' ? 'normal' : 'bold' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -236,7 +236,7 @@ export function AnnotationSettingsPanel({
aria-label="Toggle italic"
data-state={annotation.style.fontStyle === 'italic' ? 'on' : 'off'}
onClick={() => onStyleChange({ fontStyle: annotation.style.fontStyle === 'italic' ? 'normal' : 'italic' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -245,7 +245,7 @@ export function AnnotationSettingsPanel({
aria-label="Toggle underline"
data-state={annotation.style.textDecoration === 'underline' ? 'on' : 'off'}
onClick={() => onStyleChange({ textDecoration: annotation.style.textDecoration === 'underline' ? 'none' : 'underline' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -256,7 +256,7 @@ export function AnnotationSettingsPanel({
value="left"
aria-label="Align left"
onClick={() => onStyleChange({ textAlign: 'left' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -264,7 +264,7 @@ export function AnnotationSettingsPanel({
value="center"
aria-label="Align center"
onClick={() => onStyleChange({ textAlign: 'center' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -272,7 +272,7 @@ export function AnnotationSettingsPanel({
value="right"
aria-label="Align right"
onClick={() => onStyleChange({ textAlign: 'right' })}
- className="h-8 w-8 data-[state=on]:bg-[#34B27B] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
+ className="h-8 w-8 data-[state=on]:bg-[#2563EB] data-[state=on]:text-white text-slate-400 hover:bg-white/5 hover:text-slate-200"
>
@@ -378,7 +378,7 @@ export function AnnotationSettingsPanel({
fileInputRef.current?.click()}
variant="outline"
- className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all py-8"
+ className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all py-8"
>
Upload Image
@@ -421,7 +421,7 @@ export function AnnotationSettingsPanel({
className={cn(
"h-16 rounded-lg border flex items-center justify-center transition-all p-2",
annotation.figureData?.arrowDirection === direction
- ? "bg-[#34B27B] border-[#34B27B]"
+ ? "bg-[#2563EB] border-[#2563EB]"
: "bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20"
)}
>
@@ -465,17 +465,17 @@ export function AnnotationSettingsPanel({
>
- {annotation.figureData?.color || '#34B27B'}
+ {annotation.figureData?.color || '#2563EB'}
{
const newFigureData: FigureData = {
diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx
index e95b020f..e904d5a2 100644
--- a/src/components/video-editor/CropControl.tsx
+++ b/src/components/video-editor/CropControl.tsx
@@ -170,7 +170,7 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
{showSuccess ? (
<>
-
-
+
+
-
-
-
-
{/* Added flex container */}
+
Export Complete
Your {formatLabel.toLowerCase()} is ready
- {exportedFilePath && ( // Only show button if path exists
+ {exportedFilePath && (
)}
+ {exportedFilePath && (
+
+ {exportedFilePath.split('/').pop()}
+
+ )}
>
) : (
<>
{isExporting ? (
-
-
+
+
) : (
@@ -199,13 +201,13 @@ export function ExportDialog({
// Show render progress if available, otherwise animated indeterminate bar
renderProgress !== undefined && renderProgress > 0 ? (
) : (
)}
diff --git a/src/components/video-editor/FormatSelector.tsx b/src/components/video-editor/FormatSelector.tsx
index 4d13e9af..23007c71 100644
--- a/src/components/video-editor/FormatSelector.tsx
+++ b/src/components/video-editor/FormatSelector.tsx
@@ -47,9 +47,9 @@ export function FormatSelector({
onClick={() => onFormatChange(option.value)}
className={cn(
'relative flex flex-col items-center gap-2 p-4 rounded-xl border transition-all duration-200',
- 'focus:outline-none focus:ring-2 focus:ring-[#34B27B]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
+ 'focus:outline-none focus:ring-2 focus:ring-[#2563EB]/50 focus:ring-offset-2 focus:ring-offset-[#09090b]',
isSelected
- ? 'bg-[#34B27B]/10 border-[#34B27B]/50 text-white'
+ ? 'bg-[#2563EB]/10 border-[#2563EB]/50 text-white'
: 'bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:border-white/20 hover:text-slate-200',
disabled && 'opacity-50 cursor-not-allowed'
)}
@@ -57,7 +57,7 @@ export function FormatSelector({
{option.icon}
@@ -67,7 +67,7 @@ export function FormatSelector({
{option.description}
{isSelected && (
-
+
)}
);
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx
index 3edbd045..01afab3b 100644
--- a/src/components/video-editor/KeyboardShortcutsHelp.tsx
+++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx
@@ -1,65 +1,62 @@
-import { HelpCircle } from "lucide-react";
+import { HelpCircle, Settings2 } from "lucide-react";
import { useState, useEffect } from "react";
import { formatShortcut } from "@/utils/platformUtils";
+import { useShortcuts } from "@/contexts/ShortcutsContext";
+import { formatBinding, SHORTCUT_LABELS, SHORTCUT_ACTIONS } from "@/lib/shortcuts";
export function KeyboardShortcutsHelp() {
- const [shortcuts, setShortcuts] = useState({
- delete: 'Ctrl + D',
- pan: 'Shift + Ctrl + Scroll',
- zoom: 'Ctrl + Scroll'
- });
+ const { shortcuts, isMac, openConfig } = useShortcuts();
+
+ const [scrollLabels, setScrollLabels] = useState({ pan: 'Shift + Ctrl + Scroll', zoom: 'Ctrl + Scroll' });
useEffect(() => {
Promise.all([
- formatShortcut(['mod', 'D']),
formatShortcut(['shift', 'mod', 'Scroll']),
- formatShortcut(['mod', 'Scroll'])
- ]).then(([deleteKey, panKey, zoomKey]) => {
- setShortcuts({
- delete: deleteKey,
- pan: panKey,
- zoom: zoomKey
- });
- });
+ formatShortcut(['mod', 'Scroll']),
+ ]).then(([pan, zoom]) => setScrollLabels({ pan, zoom }));
}, []);
return (
-
+
+
-
Keyboard Shortcuts
+
+ Keyboard Shortcuts
+
+
+ Customize
+
+
+
-
- Add Zoom
- Z
-
-
- Add Annotation
- A
-
-
- Add Keyframe
- F
-
-
- Add Trim
- T
-
-
- Delete Selected
- {shortcuts.delete}
-
-
- Pan Timeline
- {shortcuts.pan}
-
-
- Zoom Timeline
- {shortcuts.zoom}
-
-
-
Pause/Play
-
Space
+ {SHORTCUT_ACTIONS.map((action) => (
+
+ {SHORTCUT_LABELS[action]}
+
+ {formatBinding(shortcuts[action], isMac)}
+
+
+ ))}
+
+
+
+ Pan Timeline
+ {scrollLabels.pan}
+
+
+ Zoom Timeline
+ {scrollLabels.zoom}
+
+
+ Cycle Annotations
+ Tab
+
diff --git a/src/components/video-editor/PlaybackControls.tsx b/src/components/video-editor/PlaybackControls.tsx
index 7c776dd3..e89ddccb 100644
--- a/src/components/video-editor/PlaybackControls.tsx
+++ b/src/components/video-editor/PlaybackControls.tsx
@@ -58,7 +58,7 @@ export default function PlaybackControls({
{/* Custom Track Background */}
diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx
index db5e9d82..2aa6989b 100644
--- a/src/components/video-editor/SettingsPanel.tsx
+++ b/src/components/video-editor/SettingsPanel.tsx
@@ -1,15 +1,17 @@
import { cn } from "@/lib/utils";
import { useEffect, useRef } from "react";
-import { getAssetPath } from "@/lib/assetPath";
+import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
+import { BUILT_IN_WALLPAPERS, WALLPAPER_PATHS, WALLPAPER_RELATIVE_PATHS } from "@/lib/wallpapers";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import Block from '@uiw/react-color-block';
-import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette } from "lucide-react";
+import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react";
import { toast } from "sonner";
-import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType } from "./types";
+import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
+import { SPEED_OPTIONS } from "./types";
import { CropControl } from "./CropControl";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
@@ -17,9 +19,6 @@ import { type AspectRatio } from "@/utils/aspectRatioUtils";
import type { ExportQuality, ExportFormat, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
-
-const WALLPAPER_COUNT = 18;
-const WALLPAPER_RELATIVE = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `wallpapers/wallpaper${i + 1}.jpg`);
const GRADIENTS = [
"linear-gradient( 111.6deg, rgba(114,167,232,1) 9.4%, rgba(253,129,82,1) 43.9%, rgba(253,129,82,1) 54.8%, rgba(249,202,86,1) 86.3% )",
"linear-gradient(120deg, #d4fc79 0%, #96e6a1 100%)",
@@ -58,10 +57,22 @@ interface SettingsPanelProps {
onTrimDelete?: (id: string) => void;
shadowIntensity?: number;
onShadowChange?: (intensity: number) => void;
- showBlur?: boolean;
- onBlurChange?: (showBlur: boolean) => void;
- motionBlurEnabled?: boolean;
- onMotionBlurChange?: (enabled: boolean) => void;
+ backgroundBlur?: number;
+ onBackgroundBlurChange?: (amount: number) => void;
+ zoomMotionBlur?: number;
+ onZoomMotionBlurChange?: (amount: number) => void;
+ connectZooms?: boolean;
+ onConnectZoomsChange?: (enabled: boolean) => void;
+ showCursor?: boolean;
+ onShowCursorChange?: (enabled: boolean) => void;
+ cursorSize?: number;
+ onCursorSizeChange?: (size: number) => void;
+ cursorSmoothing?: number;
+ onCursorSmoothingChange?: (smoothing: number) => void;
+ cursorMotionBlur?: number;
+ onCursorMotionBlurChange?: (amount: number) => void;
+ cursorClickBounce?: number;
+ onCursorClickBounceChange?: (amount: number) => void;
borderRadius?: number;
onBorderRadiusChange?: (radius: number) => void;
padding?: number;
@@ -82,6 +93,8 @@ interface SettingsPanelProps {
gifSizePreset?: GifSizePreset;
onGifSizePresetChange?: (preset: GifSizePreset) => void;
gifOutputDimensions?: { width: number; height: number };
+ onSaveProject?: () => void;
+ onLoadProject?: () => void;
onExport?: () => void;
selectedAnnotationId?: string | null;
annotationRegions?: AnnotationRegion[];
@@ -90,6 +103,10 @@ interface SettingsPanelProps {
onAnnotationStyleChange?: (id: string, style: Partial
) => void;
onAnnotationFigureDataChange?: (id: string, figureData: any) => void;
onAnnotationDelete?: (id: string) => void;
+ selectedSpeedId?: string | null;
+ selectedSpeedValue?: PlaybackSpeed | null;
+ onSpeedChange?: (speed: PlaybackSpeed) => void;
+ onSpeedDelete?: (id: string) => void;
}
export default SettingsPanel;
@@ -114,11 +131,23 @@ export function SettingsPanel({
onTrimDelete,
shadowIntensity = 0,
onShadowChange,
- showBlur,
- onBlurChange,
- motionBlurEnabled = false,
- onMotionBlurChange,
- borderRadius = 0,
+ backgroundBlur = 0,
+ onBackgroundBlurChange,
+ zoomMotionBlur = 0,
+ onZoomMotionBlurChange,
+ connectZooms = true,
+ onConnectZoomsChange,
+ showCursor = false,
+ onShowCursorChange,
+ cursorSize = 5,
+ onCursorSizeChange,
+ cursorSmoothing = 2,
+ onCursorSmoothingChange,
+ cursorMotionBlur = 0.35,
+ onCursorMotionBlurChange,
+ cursorClickBounce = 1,
+ onCursorClickBounceChange,
+ borderRadius = 12.5,
onBorderRadiusChange,
padding = 50,
onPaddingChange,
@@ -137,6 +166,8 @@ export function SettingsPanel({
gifSizePreset = 'medium',
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
+ onSaveProject,
+ onLoadProject,
onExport,
selectedAnnotationId,
annotationRegions = [],
@@ -145,8 +176,12 @@ export function SettingsPanel({
onAnnotationStyleChange,
onAnnotationFigureDataChange,
onAnnotationDelete,
+ selectedSpeedId,
+ selectedSpeedValue,
+ onSpeedChange,
+ onSpeedDelete,
}: SettingsPanelProps) {
- const [wallpaperPaths, setWallpaperPaths] = useState([]);
+ const [wallpaperPreviewPaths, setWallpaperPreviewPaths] = useState([]);
const [customImages, setCustomImages] = useState([]);
const fileInputRef = useRef(null);
@@ -154,10 +189,12 @@ export function SettingsPanel({
let mounted = true
;(async () => {
try {
- const resolved = await Promise.all(WALLPAPER_RELATIVE.map(p => getAssetPath(p)))
- if (mounted) setWallpaperPaths(resolved)
+ const resolved = await Promise.all(
+ WALLPAPER_RELATIVE_PATHS.map(async (path) => getRenderableAssetUrl(await getAssetPath(path)))
+ )
+ if (mounted) setWallpaperPreviewPaths(resolved)
} catch (err) {
- if (mounted) setWallpaperPaths(WALLPAPER_RELATIVE.map(p => `/${p}`))
+ if (mounted) setWallpaperPreviewPaths(WALLPAPER_PATHS)
}
})()
return () => { mounted = false }
@@ -165,7 +202,7 @@ export function SettingsPanel({
const colorPalette = [
'#FF0000', '#FFD700', '#00FF00', '#FFFFFF', '#0000FF', '#FF6B00',
'#9B59B6', '#E91E63', '#00BCD4', '#FF5722', '#8BC34A', '#FFC107',
- '#34B27B', '#000000', '#607D8B', '#795548',
+ '#2563EB', '#000000', '#607D8B', '#795548',
];
const [selectedColor, setSelectedColor] = useState('#ADADAD');
@@ -230,7 +267,7 @@ export function SettingsPanel({
setCustomImages(prev => prev.filter(img => img !== imageUrl));
// If the removed image was selected, clear selection
if (selected === imageUrl) {
- onWallpaperChange(wallpaperPaths[0] || WALLPAPER_RELATIVE[0]);
+ onWallpaperChange(WALLPAPER_PATHS[0]);
}
};
@@ -261,7 +298,7 @@ export function SettingsPanel({
Zoom Level
{zoomEnabled && selectedZoomDepth && (
-
+
{ZOOM_DEPTH_OPTIONS.find(o => o.depth === selectedZoomDepth)?.label}
)}
@@ -282,7 +319,7 @@ export function SettingsPanel({
"duration-200 ease-out",
zoomEnabled ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
isActive
- ? "border-[#34B27B] bg-[#34B27B] text-white shadow-[#34B27B]/20"
+ ? "border-[#2563EB] bg-[#2563EB] text-white shadow-[#2563EB]/20"
: "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
)}
>
@@ -321,30 +358,172 @@ export function SettingsPanel({
)}
+
+
+ Playback Speed
+ {selectedSpeedId && selectedSpeedValue && (
+
+ {SPEED_OPTIONS.find(o => o.speed === selectedSpeedValue)?.label ?? `${selectedSpeedValue}×`}
+
+ )}
+
+
+ {SPEED_OPTIONS.map((option) => {
+ const isActive = selectedSpeedValue === option.speed;
+ return (
+ onSpeedChange?.(option.speed)}
+ className={cn(
+ "h-auto w-full rounded-lg border px-1 py-2 text-center shadow-sm transition-all",
+ "duration-200 ease-out",
+ selectedSpeedId ? "opacity-100 cursor-pointer" : "opacity-40 cursor-not-allowed",
+ isActive
+ ? "border-[#d97706] bg-[#d97706] text-white shadow-[#d97706]/20"
+ : "border-white/5 bg-white/5 text-slate-400 hover:bg-white/10 hover:border-white/10 hover:text-slate-200"
+ )}
+ >
+ {option.label}
+
+ );
+ })}
+
+ {!selectedSpeedId && (
+
Select a speed region to adjust
+ )}
+ {selectedSpeedId && (
+
selectedSpeedId && onSpeedDelete?.(selectedSpeedId)}
+ variant="destructive"
+ size="sm"
+ className="mt-2 w-full gap-2 bg-red-500/10 text-red-400 border border-red-500/20 hover:bg-red-500/20 hover:border-red-500/30 transition-all h-8 text-xs"
+ >
+
+ Delete Speed Region
+
+ )}
+
+
-
+
Video Effects
-
Motion Blur
+
Show Cursor
+
+
+
Background Blur
+
{backgroundBlur.toFixed(1)}px
+
+
onBackgroundBlurChange?.(values[0])}
+ min={0}
+ max={8}
+ step={0.25}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
+
+
+
Zoom Motion Blur
+
{zoomMotionBlur.toFixed(2)}×
+
+
onZoomMotionBlurChange?.(values[0])}
+ min={0}
+ max={2}
+ step={0.05}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
-
Blur BG
+
Connect Zooms
+
+
+
+
+
+
+
Cursor Size
+
{cursorSize.toFixed(2)}×
+
+
onCursorSizeChange?.(values[0])}
+ min={0.5}
+ max={10}
+ step={0.05}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
Cursor Smoothing
+
{cursorSmoothing <= 0 ? 'Off' : cursorSmoothing.toFixed(2)}
+
+
onCursorSmoothingChange?.(values[0])}
+ min={0}
+ max={2}
+ step={0.01}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
+
+
+
Cursor Motion Blur
+
{cursorMotionBlur.toFixed(2)}×
+
+
onCursorMotionBlurChange?.(values[0])}
+ min={0}
+ max={2}
+ step={0.05}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ />
+
+
+
+
Cursor Click Bounce
+
{cursorClickBounce.toFixed(2)}×
+
+
onCursorClickBounceChange?.(values[0])}
+ min={0}
+ max={5}
+ step={0.05}
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -361,7 +540,7 @@ export function SettingsPanel({
min={0}
max={1}
step={0.01}
- className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -373,9 +552,9 @@ export function SettingsPanel({
value={[borderRadius]}
onValueChange={(values) => onBorderRadiusChange?.(values[0])}
min={0}
- max={16}
+ max={25}
step={0.5}
- className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -389,7 +568,7 @@ export function SettingsPanel({
min={0}
max={100}
step={1}
- className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
+ className="w-full [&_[role=slider]]:bg-[#2563EB] [&_[role=slider]]:border-[#2563EB] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
@@ -408,16 +587,16 @@ export function SettingsPanel({
- Image
- Color
- Gradient
+ Image
+ Color
+ Gradient
@@ -432,7 +611,7 @@ export function SettingsPanel({
fileInputRef.current?.click()}
variant="outline"
- className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#34B27B] hover:text-white hover:border-[#34B27B] transition-all h-7 text-[10px]"
+ className="w-full gap-2 bg-white/5 text-slate-200 border-white/10 hover:bg-[#2563EB] hover:text-white hover:border-[#2563EB] transition-all h-7 text-[10px]"
>
Upload Custom
@@ -447,8 +626,8 @@ export function SettingsPanel({
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 relative group shadow-sm",
isSelected
- ? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
- : "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
+ ? "border-[#2563EB] ring-1 ring-[#2563EB]/30"
+ : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ backgroundImage: `url(${imageUrl})`, backgroundSize: "cover", backgroundPosition: "center" }}
onClick={() => onWallpaperChange(imageUrl)}
@@ -464,28 +643,34 @@ export function SettingsPanel({
);
})}
- {(wallpaperPaths.length > 0 ? wallpaperPaths : WALLPAPER_RELATIVE.map(p => `/${p}`)).map((path) => {
+ {(wallpaperPreviewPaths.length > 0 ? wallpaperPreviewPaths : WALLPAPER_PATHS).map((previewPath, index) => {
+ const wallpaper = BUILT_IN_WALLPAPERS[index];
+ const wallpaperValue = WALLPAPER_PATHS[index] ?? previewPath;
const isSelected = (() => {
if (!selected) return false;
- if (selected === path) return true;
+ if (selected === wallpaperValue || selected === previewPath) return true;
try {
const clean = (s: string) => s.replace(/^file:\/\//, '').replace(/^\//, '')
- if (clean(selected).endsWith(clean(path))) return true;
- if (clean(path).endsWith(clean(selected))) return true;
+ if (clean(selected).endsWith(clean(wallpaperValue))) return true;
+ if (clean(wallpaperValue).endsWith(clean(selected))) return true;
+ if (clean(selected).endsWith(clean(previewPath))) return true;
+ if (clean(previewPath).endsWith(clean(selected))) return true;
} catch {}
return false;
})();
return (
onWallpaperChange(path)}
+ aria-label={wallpaper?.label ?? `Wallpaper ${index + 1}`}
+ title={wallpaper?.label ?? `Wallpaper ${index + 1}`}
+ style={{ backgroundImage: `url(${previewPath})`, backgroundSize: "cover", backgroundPosition: "center" }}
+ onClick={() => onWallpaperChange(wallpaperValue)}
role="button"
/>
)
@@ -518,8 +703,8 @@ export function SettingsPanel({
className={cn(
"aspect-square w-9 h-9 rounded-md border-2 overflow-hidden cursor-pointer transition-all duration-200 shadow-sm",
gradient === g
- ? "border-[#34B27B] ring-1 ring-[#34B27B]/30"
- : "border-white/10 hover:border-[#34B27B]/40 opacity-80 hover:opacity-100 bg-white/5"
+ ? "border-[#2563EB] ring-1 ring-[#2563EB]/30"
+ : "border-white/10 hover:border-[#2563EB]/40 opacity-80 hover:opacity-100 bg-white/5"
)}
style={{ background: g }}
aria-label={`Gradient ${idx + 1}`}
@@ -567,7 +752,7 @@ export function SettingsPanel({
setShowCropDropdown(false)}
size="lg"
- className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
+ className="bg-[#2563EB] hover:bg-[#2563EB]/90 text-white"
>
Done
@@ -583,7 +768,7 @@ export function SettingsPanel({
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
exportFormat === 'mp4'
- ? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
+ ? "bg-[#2563EB]/10 border-[#2563EB]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
@@ -595,7 +780,7 @@ export function SettingsPanel({
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
exportFormat === 'gif'
- ? "bg-[#34B27B]/10 border-[#34B27B]/50 text-white"
+ ? "bg-[#2563EB]/10 border-[#2563EB]/50 text-white"
: "bg-white/5 border-white/10 text-slate-400 hover:bg-white/10 hover:text-slate-200"
)}
>
@@ -675,18 +860,39 @@ export function SettingsPanel({
)}
+
+
+
+ Load Project
+
+
+
+ Save Project
+
+
+
Export {exportFormat === 'gif' ? 'GIF' : 'Video'}
@@ -696,17 +902,17 @@ export function SettingsPanel({
{
- window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen/issues/new/choose');
+ window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash/issues/new/choose');
}}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
-
+
Report Bug
{
- window.electronAPI?.openExternalUrl('https://github.com/siddharthvaddem/openscreen');
+ window.electronAPI?.openExternalUrl('https://github.com/webadderall/bug-free-succotash');
}}
className="flex-1 flex items-center justify-center gap-1.5 text-[10px] text-slate-500 hover:text-slate-300 py-1.5 transition-colors"
>
diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx
new file mode 100644
index 00000000..b4d682aa
--- /dev/null
+++ b/src/components/video-editor/ShortcutsConfigDialog.tsx
@@ -0,0 +1,223 @@
+import { useCallback, useEffect, useState } from 'react';
+import { Keyboard, RotateCcw } from 'lucide-react';
+import { toast } from 'sonner';
+
+import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import {
+ DEFAULT_SHORTCUTS,
+ FIXED_SHORTCUTS,
+ SHORTCUT_ACTIONS,
+ SHORTCUT_LABELS,
+ findConflict,
+ formatBinding,
+ type ShortcutAction,
+ type ShortcutBinding,
+ type ShortcutConflict,
+ type ShortcutsConfig,
+} from '@/lib/shortcuts';
+import { useShortcuts } from '@/contexts/ShortcutsContext';
+
+const MODIFIER_KEYS = new Set(['Control', 'Shift', 'Alt', 'Meta']);
+
+export function ShortcutsConfigDialog() {
+ const { shortcuts, isMac, isConfigOpen, closeConfig, setShortcuts, persistShortcuts } =
+ useShortcuts();
+
+ const [draft, setDraft] = useState(shortcuts);
+ const [captureFor, setCaptureFor] = useState(null);
+ const [conflict, setConflict] = useState<{ forAction: ShortcutAction; pending: ShortcutBinding; conflictWith: ShortcutConflict } | null>(null);
+
+ useEffect(() => {
+ if (isConfigOpen) {
+ setDraft(shortcuts);
+ setCaptureFor(null);
+ setConflict(null);
+ }
+ }, [isConfigOpen, shortcuts]);
+
+ useEffect(() => {
+ if (!captureFor) return;
+
+ const handleCapture = (e: KeyboardEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.key === 'Escape') {
+ setCaptureFor(null);
+ return;
+ }
+
+ if (MODIFIER_KEYS.has(e.key)) return;
+
+ const binding: ShortcutBinding = {
+ key: e.key.toLowerCase(),
+ ...(e.ctrlKey || e.metaKey ? { ctrl: true } : {}),
+ ...(e.shiftKey ? { shift: true } : {}),
+ ...(e.altKey ? { alt: true } : {}),
+ };
+
+ const found = findConflict(binding, captureFor, draft);
+ setCaptureFor(null);
+
+ if (found?.type === 'fixed') {
+ toast.error(`This shortcut is reserved for "${found.label}" and cannot be reassigned.`);
+ return;
+ }
+
+ if (found?.type === 'configurable') {
+ setConflict({ forAction: captureFor, pending: binding, conflictWith: found });
+ return;
+ }
+
+ setDraft((prev: ShortcutsConfig) => ({ ...prev, [captureFor]: binding }));
+ };
+
+ window.addEventListener('keydown', handleCapture, { capture: true });
+ return () => window.removeEventListener('keydown', handleCapture, { capture: true });
+ }, [captureFor]);
+
+ const handleSwap = useCallback(() => {
+ if (!conflict || conflict.conflictWith.type !== 'configurable') return;
+ const { forAction, pending, conflictWith } = conflict;
+ setDraft((prev: ShortcutsConfig) => ({
+ ...prev,
+ [forAction]: pending,
+ [conflictWith.action]: prev[forAction],
+ }));
+ setConflict(null);
+ }, [conflict]);
+
+ const handleCancelConflict = useCallback(() => setConflict(null), []);
+
+ const handleSave = useCallback(async () => {
+ setShortcuts(draft);
+ await persistShortcuts(draft);
+ toast.success('Keyboard shortcuts saved');
+ closeConfig();
+ }, [draft, setShortcuts, persistShortcuts, closeConfig]);
+
+ const handleReset = useCallback(() => {
+ setDraft({ ...DEFAULT_SHORTCUTS });
+ toast.info('Reset to default shortcuts — click Save to apply');
+ }, []);
+
+ const handleClose = useCallback(() => {
+ setCaptureFor(null);
+ setConflict(null);
+ closeConfig();
+ }, [closeConfig]);
+
+ return (
+ { if (!open) handleClose(); }}>
+
+
+
+
+ Keyboard Shortcuts
+
+
+
+
+
Configurable
+ {SHORTCUT_ACTIONS.map((action) => {
+ const isCapturing = captureFor === action;
+ const hasConflict = conflict?.forAction === action;
+ return (
+
+
+ {SHORTCUT_LABELS[action]}
+ {
+ setConflict(null);
+ setCaptureFor(isCapturing ? null : action);
+ }}
+ title={isCapturing ? 'Press Esc to cancel' : 'Click to change'}
+ className={[
+ 'px-2 py-1 rounded text-xs font-mono border transition-all min-w-[90px] text-center select-none',
+ isCapturing
+ ? 'bg-[#2563EB]/20 border-[#2563EB] text-[#2563EB] animate-pulse'
+ : hasConflict
+ ? 'bg-amber-500/10 border-amber-500/50 text-amber-400'
+ : 'bg-white/5 border-white/10 text-slate-200 hover:border-[#2563EB]/50 hover:text-[#2563EB] cursor-pointer',
+ ].join(' ')}
+ >
+ {isCapturing ? 'Press a key…' : formatBinding(draft[action], isMac)}
+
+
+ {hasConflict && conflict?.conflictWith.type === 'configurable' && (
+
+
+ ⚠ Already used by {SHORTCUT_LABELS[conflict.conflictWith.action]}
+
+
+
+ Swap
+
+
+ Cancel
+
+
+
+ )}
+
+ );
+ })}
+
+
+
+
Fixed
+ {FIXED_SHORTCUTS.map(({ label, display }) => (
+
+ {label}
+
+ {display}
+
+
+ ))}
+
+
+
+ Click a shortcut then press the new key combination. Press{' '}
+ Esc to cancel.
+
+
+
+
+
+ Reset to defaults
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+ );
+}
diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx
index 354cda9f..c4e75351 100644
--- a/src/components/video-editor/VideoEditor.tsx
+++ b/src/components/video-editor/VideoEditor.tsx
@@ -1,58 +1,88 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
+import { FolderOpen } from "lucide-react";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
import PlaybackControls from "./PlaybackControls";
import TimelineEditor from "./timeline/TimelineEditor";
import { SettingsPanel } from "./SettingsPanel";
import { ExportDialog } from "./ExportDialog";
+import { DEFAULT_WALLPAPER_RELATIVE_PATH, WALLPAPER_PATHS } from "@/lib/wallpapers";
+import {
+ createProjectData,
+ deriveNextId,
+ fromFileUrl,
+ normalizeProjectEditor,
+ toFileUrl,
+ validateProjectData,
+} from "./projectPersistence";
import type { Span } from "dnd-timeline";
import {
+ DEFAULT_CURSOR_CLICK_BOUNCE,
+ DEFAULT_CURSOR_MOTION_BLUR,
+ DEFAULT_CURSOR_SIZE,
+ DEFAULT_CURSOR_SMOOTHING,
DEFAULT_ZOOM_DEPTH,
+ DEFAULT_ZOOM_MOTION_BLUR,
clampFocusToDepth,
DEFAULT_CROP_REGION,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
DEFAULT_ANNOTATION_STYLE,
DEFAULT_FIGURE_DATA,
+ DEFAULT_PLAYBACK_SPEED,
type ZoomDepth,
type ZoomFocus,
type ZoomRegion,
+ type CursorTelemetryPoint,
type TrimRegion,
type AnnotationRegion,
type CropRegion,
type FigureData,
+ type SpeedRegion,
+ type PlaybackSpeed,
} from "./types";
import { VideoExporter, GifExporter, type ExportProgress, type ExportQuality, type ExportSettings, type ExportFormat, type GifFrameRate, type GifSizePreset, GIF_SIZE_PRESETS, calculateOutputDimensions } from "@/lib/exporter";
import { type AspectRatio, getAspectRatioValue } from "@/utils/aspectRatioUtils";
import { getAssetPath } from "@/lib/assetPath";
-
-const WALLPAPER_COUNT = 18;
-const WALLPAPER_PATHS = Array.from({ length: WALLPAPER_COUNT }, (_, i) => `/wallpapers/wallpaper${i + 1}.jpg`);
+import { useShortcuts } from "@/contexts/ShortcutsContext";
+import { matchesShortcut } from "@/lib/shortcuts";
+import { detectInteractionCandidates, normalizeCursorTelemetry } from "./timeline/zoomSuggestionUtils";
export default function VideoEditor() {
const [videoPath, setVideoPath] = useState(null);
+ const [videoSourcePath, setVideoSourcePath] = useState(null);
+ const [currentProjectPath, setCurrentProjectPath] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [wallpaper, setWallpaper] = useState(WALLPAPER_PATHS[0]);
- const [shadowIntensity, setShadowIntensity] = useState(0);
- const [showBlur, setShowBlur] = useState(false);
- const [motionBlurEnabled, setMotionBlurEnabled] = useState(false);
- const [borderRadius, setBorderRadius] = useState(0);
+ const [shadowIntensity, setShadowIntensity] = useState(0.67);
+ const [backgroundBlur, setBackgroundBlur] = useState(0);
+ const [zoomMotionBlur, setZoomMotionBlur] = useState(DEFAULT_ZOOM_MOTION_BLUR);
+ const [connectZooms, setConnectZooms] = useState(true);
+ const [showCursor, setShowCursor] = useState(true);
+ const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SIZE);
+ const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SMOOTHING);
+ const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_MOTION_BLUR);
+ const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_CLICK_BOUNCE);
+ const [borderRadius, setBorderRadius] = useState(12.5);
const [padding, setPadding] = useState(50);
const [cropRegion, setCropRegion] = useState(DEFAULT_CROP_REGION);
const [zoomRegions, setZoomRegions] = useState([]);
+ const [cursorTelemetry, setCursorTelemetry] = useState([]);
const [selectedZoomId, setSelectedZoomId] = useState(null);
const [trimRegions, setTrimRegions] = useState([]);
const [selectedTrimId, setSelectedTrimId] = useState(null);
+ const [speedRegions, setSpeedRegions] = useState([]);
+ const [selectedSpeedId, setSelectedSpeedId] = useState(null);
const [annotationRegions, setAnnotationRegions] = useState([]);
const [selectedAnnotationId, setSelectedAnnotationId] = useState(null);
const [isExporting, setIsExporting] = useState(false);
@@ -66,56 +96,464 @@ export default function VideoEditor() {
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState('medium');
const [exportedFilePath, setExportedFilePath] = useState(undefined);
+ const [lastSavedSnapshot, setLastSavedSnapshot] = useState(null);
const videoPlaybackRef = useRef(null);
const nextZoomIdRef = useRef(1);
const nextTrimIdRef = useRef(1);
+ const nextSpeedIdRef = useRef(1);
+
+ const { shortcuts, isMac } = useShortcuts();
const nextAnnotationIdRef = useRef(1);
const nextAnnotationZIndexRef = useRef(1); // Track z-index for stacking order
const exporterRef = useRef(null);
+ const autoSuggestedVideoPathRef = useRef(null);
- // Helper to convert file path to proper file:// URL
- const toFileUrl = (filePath: string): string => {
- // Normalize path separators to forward slashes
- const normalized = filePath.replace(/\\/g, '/');
-
- // Check if it's a Windows absolute path (e.g., C:/Users/...)
- if (normalized.match(/^[a-zA-Z]:/)) {
- const fileUrl = `file:///${normalized}`;
- return fileUrl;
+ const applyLoadedProject = useCallback(async (candidate: unknown, path?: string | null) => {
+ if (!validateProjectData(candidate)) {
+ return false;
}
-
- // Unix-style absolute path
- const fileUrl = `file://${normalized}`;
- return fileUrl;
- };
+
+ const project = candidate;
+ const sourcePath = project.videoPath;
+ const normalizedEditor = normalizeProjectEditor(project.editor);
+
+ try {
+ videoPlaybackRef.current?.pause();
+ } catch {
+ // no-op
+ }
+ setIsPlaying(false);
+ setCurrentTime(0);
+ setDuration(0);
+
+ setError(null);
+ setVideoSourcePath(sourcePath);
+ setVideoPath(toFileUrl(sourcePath));
+ setCurrentProjectPath(path ?? null);
+
+ setWallpaper(normalizedEditor.wallpaper);
+ setShadowIntensity(normalizedEditor.shadowIntensity);
+ setBackgroundBlur(normalizedEditor.backgroundBlur);
+ setZoomMotionBlur(normalizedEditor.zoomMotionBlur);
+ setConnectZooms(normalizedEditor.connectZooms);
+ setShowCursor(normalizedEditor.showCursor);
+ setCursorSize(normalizedEditor.cursorSize);
+ setCursorSmoothing(normalizedEditor.cursorSmoothing);
+ setCursorMotionBlur(normalizedEditor.cursorMotionBlur);
+ setCursorClickBounce(normalizedEditor.cursorClickBounce);
+ setBorderRadius(normalizedEditor.borderRadius);
+ setPadding(normalizedEditor.padding);
+ setCropRegion(normalizedEditor.cropRegion);
+ setZoomRegions(normalizedEditor.zoomRegions);
+ setTrimRegions(normalizedEditor.trimRegions);
+ setSpeedRegions(normalizedEditor.speedRegions);
+ setAnnotationRegions(normalizedEditor.annotationRegions);
+ setAspectRatio(normalizedEditor.aspectRatio);
+ setExportQuality(normalizedEditor.exportQuality);
+ setExportFormat(normalizedEditor.exportFormat);
+ setGifFrameRate(normalizedEditor.gifFrameRate);
+ setGifLoop(normalizedEditor.gifLoop);
+ setGifSizePreset(normalizedEditor.gifSizePreset);
+
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedSpeedId(null);
+ setSelectedAnnotationId(null);
+
+ nextZoomIdRef.current = deriveNextId("zoom", normalizedEditor.zoomRegions.map((region) => region.id));
+ nextTrimIdRef.current = deriveNextId("trim", normalizedEditor.trimRegions.map((region) => region.id));
+ nextSpeedIdRef.current = deriveNextId("speed", normalizedEditor.speedRegions.map((region) => region.id));
+ nextAnnotationIdRef.current = deriveNextId(
+ "annotation",
+ normalizedEditor.annotationRegions.map((region) => region.id),
+ );
+ nextAnnotationZIndexRef.current =
+ normalizedEditor.annotationRegions.reduce((max, region) => Math.max(max, region.zIndex), 0) + 1;
+
+ setLastSavedSnapshot(JSON.stringify(createProjectData(sourcePath, normalizedEditor)));
+ return true;
+ }, []);
+
+ const currentProjectSnapshot = useMemo(() => {
+ const sourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
+ if (!sourcePath) {
+ return null;
+ }
+ return JSON.stringify(
+ createProjectData(sourcePath, {
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ }),
+ );
+ }, [
+ videoPath,
+ videoSourcePath,
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ ]);
+
+ const hasUnsavedChanges = Boolean(
+ currentProjectPath &&
+ currentProjectSnapshot &&
+ lastSavedSnapshot &&
+ currentProjectSnapshot !== lastSavedSnapshot,
+ );
useEffect(() => {
- async function loadVideo() {
+ async function loadInitialData() {
try {
+ const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
+ if (currentProjectResult.success && currentProjectResult.project) {
+ const restored = await applyLoadedProject(
+ currentProjectResult.project,
+ currentProjectResult.path ?? null,
+ );
+ if (restored) {
+ return;
+ }
+ }
+
const result = await window.electronAPI.getCurrentVideoPath();
-
if (result.success && result.path) {
- const videoUrl = toFileUrl(result.path);
- setVideoPath(videoUrl);
+ setVideoSourcePath(result.path);
+ setVideoPath(toFileUrl(result.path));
+ setCurrentProjectPath(null);
+ setLastSavedSnapshot(null);
} else {
- setError('No video to load. Please record or select a video.');
+ setError("No video to load. Please record or select a video.");
}
} catch (err) {
- setError('Error loading video: ' + String(err));
+ setError("Error loading video: " + String(err));
} finally {
setLoading(false);
}
}
- loadVideo();
- }, []);
+
+ loadInitialData();
+ }, [applyLoadedProject]);
+
+ const saveProject = useCallback(async (forceSaveAs: boolean) => {
+ if (!videoPath) {
+ toast.error('No video loaded');
+ return;
+ }
+
+ const sourcePath = videoSourcePath ?? fromFileUrl(videoPath);
+ if (!sourcePath) {
+ toast.error('Unable to determine source video path');
+ return;
+ }
+
+ const projectData = createProjectData(sourcePath, {
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ });
+
+ const fileNameBase = sourcePath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || `project-${Date.now()}`;
+ const projectSnapshot = JSON.stringify(projectData);
+ const result = await window.electronAPI.saveProjectFile(
+ projectData,
+ fileNameBase,
+ forceSaveAs ? undefined : currentProjectPath ?? undefined,
+ );
+
+ if (result.canceled) {
+ toast.info("Project save canceled");
+ return;
+ }
+
+ if (!result.success) {
+ toast.error(result.message || 'Failed to save project');
+ return;
+ }
+
+ if (result.path) {
+ setCurrentProjectPath(result.path);
+ }
+ setLastSavedSnapshot(projectSnapshot);
+
+ toast.success(`Project saved to ${result.path}`);
+ }, [
+ videoPath,
+ videoSourcePath,
+ currentProjectPath,
+ wallpaper,
+ shadowIntensity,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
+ borderRadius,
+ padding,
+ cropRegion,
+ zoomRegions,
+ trimRegions,
+ speedRegions,
+ annotationRegions,
+ aspectRatio,
+ exportQuality,
+ exportFormat,
+ gifFrameRate,
+ gifLoop,
+ gifSizePreset,
+ ]);
+
+ useEffect(() => {
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ if (!hasUnsavedChanges) {
+ return;
+ }
+
+ event.preventDefault();
+ event.returnValue = '';
+ };
+
+ window.addEventListener('beforeunload', handleBeforeUnload);
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
+ }, [hasUnsavedChanges]);
+
+ useEffect(() => {
+ window.electronAPI.setHasUnsavedChanges(hasUnsavedChanges);
+ }, [hasUnsavedChanges]);
+
+ useEffect(() => {
+ const cleanup = window.electronAPI.onRequestSaveBeforeClose(async () => {
+ await saveProject(false);
+ });
+
+ return () => cleanup?.();
+ }, [saveProject]);
+
+ const handleSaveProject = useCallback(async () => {
+ await saveProject(false);
+ }, [saveProject]);
+
+ const handleSaveProjectAs = useCallback(async () => {
+ await saveProject(true);
+ }, [saveProject]);
+
+ const handleLoadProject = useCallback(async () => {
+ const result = await window.electronAPI.loadProjectFile();
+
+ if (result.canceled) {
+ return;
+ }
+
+ if (!result.success) {
+ toast.error(result.message || 'Failed to load project');
+ return;
+ }
+
+ const restored = await applyLoadedProject(result.project, result.path ?? null);
+ if (!restored) {
+ toast.error('Invalid project file format');
+ return;
+ }
+
+ toast.success(`Project loaded from ${result.path}`);
+ }, [applyLoadedProject]);
+
+ useEffect(() => {
+ const removeLoadListener = window.electronAPI.onMenuLoadProject(handleLoadProject);
+ const removeSaveListener = window.electronAPI.onMenuSaveProject(handleSaveProject);
+ const removeSaveAsListener = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs);
+
+ return () => {
+ removeLoadListener?.();
+ removeSaveListener?.();
+ removeSaveAsListener?.();
+ };
+ }, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
+
+ useEffect(() => {
+ let mounted = true;
+
+ async function loadCursorTelemetry() {
+ if (!videoPath) {
+ if (mounted) {
+ setCursorTelemetry([]);
+ }
+ return;
+ }
+
+ try {
+ const result = await window.electronAPI.getCursorTelemetry(fromFileUrl(videoPath));
+ if (mounted) {
+ setCursorTelemetry(result.success ? result.samples : []);
+ }
+ } catch (telemetryError) {
+ console.warn('Unable to load cursor telemetry:', telemetryError);
+ if (mounted) {
+ setCursorTelemetry([]);
+ }
+ }
+ }
+
+ loadCursorTelemetry();
+
+ return () => {
+ mounted = false;
+ };
+ }, [videoPath]);
+
+ const normalizedCursorTelemetry = useMemo(() => {
+ if (cursorTelemetry.length === 0) {
+ return [] as CursorTelemetryPoint[];
+ }
+
+ const totalMs = Math.max(0, Math.round(duration * 1000));
+ return normalizeCursorTelemetry(cursorTelemetry, totalMs > 0 ? totalMs : Number.MAX_SAFE_INTEGER);
+ }, [cursorTelemetry, duration]);
+
+ useEffect(() => {
+ if (!videoPath || duration <= 0 || zoomRegions.length > 0 || normalizedCursorTelemetry.length < 2) {
+ return;
+ }
+
+ if (autoSuggestedVideoPathRef.current === videoPath) {
+ return;
+ }
+
+ const totalMs = Math.max(0, Math.round(duration * 1000));
+ if (totalMs <= 0) {
+ return;
+ }
+
+ const candidates = detectInteractionCandidates(normalizedCursorTelemetry);
+ if (candidates.length === 0) {
+ autoSuggestedVideoPathRef.current = videoPath;
+ return;
+ }
+
+ const DEFAULT_DURATION_MS = 1100;
+ const MIN_SPACING_MS = 1800;
+ const sortedCandidates = [...candidates].sort((a, b) => b.strength - a.strength);
+ const acceptedCenters: number[] = [];
+
+ setZoomRegions((prev) => {
+ if (prev.length > 0) {
+ return prev;
+ }
+
+ const reservedSpans: Array<{ start: number; end: number }> = [];
+ const additions: ZoomRegion[] = [];
+ let nextId = nextZoomIdRef.current;
+
+ sortedCandidates.forEach((candidate) => {
+ const tooCloseToAccepted = acceptedCenters.some(
+ (center) => Math.abs(center - candidate.centerTimeMs) < MIN_SPACING_MS,
+ );
+ if (tooCloseToAccepted) {
+ return;
+ }
+
+ const centeredStart = Math.round(candidate.centerTimeMs - DEFAULT_DURATION_MS / 2);
+ const startMs = Math.max(0, Math.min(centeredStart, totalMs - DEFAULT_DURATION_MS));
+ const endMs = Math.min(totalMs, startMs + DEFAULT_DURATION_MS);
+
+ const hasOverlap = reservedSpans.some(
+ (span) => endMs > span.start && startMs < span.end,
+ );
+ if (hasOverlap) {
+ return;
+ }
+
+ additions.push({
+ id: `zoom-${nextId++}`,
+ startMs,
+ endMs,
+ depth: DEFAULT_ZOOM_DEPTH,
+ focus: clampFocusToDepth(candidate.focus, DEFAULT_ZOOM_DEPTH),
+ });
+ reservedSpans.push({ start: startMs, end: endMs });
+ acceptedCenters.push(candidate.centerTimeMs);
+ });
+
+ if (additions.length === 0) {
+ return prev;
+ }
+
+ nextZoomIdRef.current = nextId;
+ return [...prev, ...additions];
+ });
+
+ autoSuggestedVideoPathRef.current = videoPath;
+ }, [videoPath, duration, normalizedCursorTelemetry, zoomRegions.length]);
// Initialize default wallpaper with resolved asset path
useEffect(() => {
let mounted = true;
(async () => {
try {
- const resolvedPath = await getAssetPath('wallpapers/wallpaper1.jpg');
+ const resolvedPath = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH);
if (mounted) {
setWallpaper(resolvedPath);
}
@@ -181,6 +619,21 @@ export default function VideoEditor() {
setSelectedAnnotationId(null);
}, []);
+ const handleZoomSuggested = useCallback((span: Span, focus: ZoomFocus) => {
+ const id = `zoom-${nextZoomIdRef.current++}`;
+ const newRegion: ZoomRegion = {
+ id,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ depth: DEFAULT_ZOOM_DEPTH,
+ focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
+ };
+ setZoomRegions((prev) => [...prev, newRegion]);
+ setSelectedZoomId(id);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ }, []);
+
const handleTrimAdded = useCallback((span: Span) => {
const id = `trim-${nextTrimIdRef.current++}`;
const newRegion: TrimRegion = {
@@ -264,6 +717,60 @@ export default function VideoEditor() {
}
}, [selectedTrimId]);
+ const handleSelectSpeed = useCallback((id: string | null) => {
+ setSelectedSpeedId(id);
+ if (id) {
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ }
+ }, []);
+
+ const handleSpeedAdded = useCallback((span: Span) => {
+ const id = `speed-${nextSpeedIdRef.current++}`;
+ const newRegion: SpeedRegion = {
+ id,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ speed: DEFAULT_PLAYBACK_SPEED,
+ };
+ setSpeedRegions((prev) => [...prev, newRegion]);
+ setSelectedSpeedId(id);
+ setSelectedZoomId(null);
+ setSelectedTrimId(null);
+ setSelectedAnnotationId(null);
+ }, []);
+
+ const handleSpeedSpanChange = useCallback((id: string, span: Span) => {
+ setSpeedRegions((prev) =>
+ prev.map((region) =>
+ region.id === id
+ ? {
+ ...region,
+ startMs: Math.round(span.start),
+ endMs: Math.round(span.end),
+ }
+ : region,
+ ),
+ );
+ }, []);
+
+ const handleSpeedDelete = useCallback((id: string) => {
+ setSpeedRegions((prev) => prev.filter((region) => region.id !== id));
+ if (selectedSpeedId === id) {
+ setSelectedSpeedId(null);
+ }
+ }, [selectedSpeedId]);
+
+ const handleSpeedChange = useCallback((speed: PlaybackSpeed) => {
+ if (!selectedSpeedId) return;
+ setSpeedRegions((prev) =>
+ prev.map((region) =>
+ region.id === selectedSpeedId ? { ...region, speed } : region,
+ ),
+ );
+ }, [selectedSpeedId]);
+
const handleAnnotationAdded = useCallback((span: Span) => {
const id = `annotation-${nextAnnotationIdRef.current++}`;
const zIndex = nextAnnotationZIndexRef.current++; // Assign z-index based on creation order
@@ -399,7 +906,7 @@ export default function VideoEditor() {
e.preventDefault();
}
- if (e.key === ' ' || e.code === 'Space') {
+ if (matchesShortcut(e, shortcuts.playPause, isMac)) {
// Allow space only in inputs/textareas
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return;
@@ -419,7 +926,7 @@ export default function VideoEditor() {
window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
- }, []);
+ }, [shortcuts, isMac]);
useEffect(() => {
if (selectedZoomId && !zoomRegions.some((region) => region.id === selectedZoomId)) {
@@ -439,6 +946,12 @@ export default function VideoEditor() {
}
}, [selectedAnnotationId, annotationRegions]);
+ useEffect(() => {
+ if (selectedSpeedId && !speedRegions.some((region) => region.id === selectedSpeedId)) {
+ setSelectedSpeedId(null);
+ }
+ }, [selectedSpeedId, speedRegions]);
+
const handleExport = useCallback(async (settings: ExportSettings) => {
if (!videoPath) {
toast.error('No video loaded');
@@ -457,6 +970,7 @@ export default function VideoEditor() {
try {
const wasPlaying = isPlaying;
+ const restoreTime = video.currentTime;
if (wasPlaying) {
videoPlaybackRef.current?.pause();
}
@@ -483,15 +997,23 @@ export default function VideoEditor() {
wallpaper,
zoomRegions,
trimRegions,
+ speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
- showBlur,
- motionBlurEnabled,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
borderRadius,
padding,
videoPadding: padding,
cropRegion,
annotationRegions,
+ cursorTelemetry: normalizedCursorTelemetry,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -509,7 +1031,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
- if (saveResult.cancelled) {
+ if (saveResult.canceled) {
toast.info('Export cancelled');
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
@@ -610,14 +1132,22 @@ export default function VideoEditor() {
wallpaper,
zoomRegions,
trimRegions,
+ speedRegions,
showShadow: shadowIntensity > 0,
shadowIntensity,
- showBlur,
- motionBlurEnabled,
+ backgroundBlur,
+ zoomMotionBlur,
+ connectZooms,
borderRadius,
padding,
cropRegion,
annotationRegions,
+ cursorTelemetry: normalizedCursorTelemetry,
+ showCursor,
+ cursorSize,
+ cursorSmoothing,
+ cursorMotionBlur,
+ cursorClickBounce,
previewWidth,
previewHeight,
onProgress: (progress: ExportProgress) => {
@@ -635,7 +1165,7 @@ export default function VideoEditor() {
const saveResult = await window.electronAPI.saveExportedVideo(arrayBuffer, fileName);
- if (saveResult.cancelled) {
+ if (saveResult.canceled) {
toast.info('Export cancelled');
} else if (saveResult.success && saveResult.path) {
showExportSuccessToast(saveResult.path);
@@ -652,6 +1182,9 @@ export default function VideoEditor() {
if (wasPlaying) {
videoPlaybackRef.current?.play();
+ } else {
+ video.currentTime = restoreTime;
+ await videoPlaybackRef.current?.refreshFrame();
}
} catch (error) {
console.error('Export error:', error);
@@ -659,6 +1192,9 @@ export default function VideoEditor() {
setExportError(errorMessage);
toast.error(`Export failed: ${errorMessage}`);
} finally {
+ if (!isPlaying) {
+ await videoPlaybackRef.current?.refreshFrame().catch(() => undefined);
+ }
setIsExporting(false);
exporterRef.current = null;
// Reset dialog state to ensure it can be opened again on next export
@@ -666,7 +1202,7 @@ export default function VideoEditor() {
setShowExportDialog(false);
setExportProgress(null);
}
- }, [videoPath, wallpaper, zoomRegions, trimRegions, shadowIntensity, showBlur, motionBlurEnabled, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
+ }, [videoPath, wallpaper, zoomRegions, trimRegions, speedRegions, shadowIntensity, backgroundBlur, zoomMotionBlur, connectZooms, showCursor, normalizedCursorTelemetry, cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce, borderRadius, padding, cropRegion, annotationRegions, isPlaying, aspectRatio, exportQuality]);
const handleOpenExportDialog = useCallback(() => {
if (!videoPath) {
@@ -707,7 +1243,7 @@ export default function VideoEditor() {
const handleCancelExport = useCallback(() => {
if (exporterRef.current) {
exporterRef.current.cancel();
- toast.info('Export cancelled');
+ toast.info('Export canceled');
setShowExportDialog(false);
setIsExporting(false);
setExportProgress(null);
@@ -722,7 +1258,7 @@ export default function VideoEditor() {
}, []);
const showExportSuccessToast = useCallback((filePath: string) => {
- toast.success(`Video exported successfully to ${filePath}`, {
+ toast.success(`Exported successfully to ${filePath}`, {
action: {
label: 'Show in Folder',
onClick: async () => {
@@ -740,6 +1276,17 @@ export default function VideoEditor() {
});
}, []);
+ const openRecordingsFolder = useCallback(async () => {
+ try {
+ const result = await window.electronAPI.openRecordingsFolder();
+ if (!result.success) {
+ toast.error(result.message || result.error || 'Failed to open recordings folder.');
+ }
+ } catch (error) {
+ toast.error(`Failed to open recordings folder: ${String(error)}`);
+ }
+ }, []);
+
if (loading) {
return (
@@ -750,19 +1297,39 @@ export default function VideoEditor() {
if (error) {
return (
-
{error}
+
+
{error}
+
+ Load Project File
+
+
);
}
return (
-
+
-
+
Recordly
+
void openRecordingsFolder()}
+ className="absolute right-4 inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-white/90 transition hover:bg-white/8 hover:text-white cursor-pointer"
+ style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}
+ title="Open recordings folder"
+ aria-label="Open recordings folder"
+ >
+
+ Manage recordings
+
@@ -776,6 +1343,7 @@ export default function VideoEditor() {
0}
shadowIntensity={shadowIntensity}
- showBlur={showBlur}
- motionBlurEnabled={motionBlurEnabled}
+ backgroundBlur={backgroundBlur}
+ zoomMotionBlur={zoomMotionBlur}
+ connectZooms={connectZooms}
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
trimRegions={trimRegions}
+ speedRegions={speedRegions}
annotationRegions={annotationRegions}
selectedAnnotationId={selectedAnnotationId}
onSelectAnnotation={handleSelectAnnotation}
onAnnotationPositionChange={handleAnnotationPositionChange}
onAnnotationSizeChange={handleAnnotationSizeChange}
+ cursorTelemetry={normalizedCursorTelemetry}
+ showCursor={showCursor}
+ cursorSize={cursorSize}
+ cursorSmoothing={cursorSmoothing}
+ cursorMotionBlur={cursorMotionBlur}
+ cursorClickBounce={cursorClickBounce}
/>
@@ -827,13 +1403,15 @@ export default function VideoEditor() {
{/* Timeline section */}
-
+
r.id === selectedSpeedId)?.speed ?? null : null}
+ onSpeedChange={handleSpeedChange}
+ onSpeedDelete={handleSpeedDelete}
/>
@@ -923,4 +1525,4 @@ export default function VideoEditor() {
/>
);
-}
\ No newline at end of file
+}
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx
index 4dc491e2..3ecca300 100644
--- a/src/components/video-editor/VideoPlayback.tsx
+++ b/src/components/video-editor/VideoPlayback.tsx
@@ -1,18 +1,49 @@
import type React from "react";
import { useEffect, useRef, useImperativeHandle, forwardRef, useState, useMemo, useCallback } from "react";
-import { getAssetPath } from "@/lib/assetPath";
+import { getAssetPath, getRenderableAssetUrl } from "@/lib/assetPath";
+import { DEFAULT_WALLPAPER_PATH, DEFAULT_WALLPAPER_RELATIVE_PATH } from "@/lib/wallpapers";
import { Application, Container, Sprite, Graphics, BlurFilter, Texture, VideoSource } from 'pixi.js';
-import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type AnnotationRegion } from "./types";
-import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from "./videoPlayback/constants";
+import { MotionBlurFilter } from 'pixi-filters/motion-blur';
+import { ZOOM_DEPTH_SCALES, type ZoomRegion, type ZoomFocus, type ZoomDepth, type TrimRegion, type SpeedRegion, type AnnotationRegion, type CursorTelemetryPoint } from "./types";
+import { DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX } from "./videoPlayback/constants";
+import { DEFAULT_CURSOR_CONFIG, PixiCursorOverlay, preloadCursorAssets } from "./videoPlayback/cursorRenderer";
import { clamp01 } from "./videoPlayback/mathUtils";
import { findDominantRegion } from "./videoPlayback/zoomRegionUtils";
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
-import { applyZoomTransform } from "./videoPlayback/zoomTransform";
+import { applyZoomTransform, computeFocusFromTransform, computeZoomTransform, createMotionBlurState, type MotionBlurState } from "./videoPlayback/zoomTransform";
import { createVideoEventHandlers } from "./videoPlayback/videoEventHandlers";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
import { AnnotationOverlay } from "./AnnotationOverlay";
+import {
+ DEFAULT_CURSOR_CLICK_BOUNCE,
+ DEFAULT_CURSOR_MOTION_BLUR,
+ DEFAULT_CURSOR_SIZE,
+ DEFAULT_CURSOR_SMOOTHING,
+} from "./types";
+
+type PlaybackAnimationState = {
+ scale: number;
+ appliedScale: number;
+ focusX: number;
+ focusY: number;
+ progress: number;
+ x: number;
+ y: number;
+};
+
+function createPlaybackAnimationState(): PlaybackAnimationState {
+ return {
+ scale: 1,
+ appliedScale: 1,
+ focusX: DEFAULT_FOCUS.cx,
+ focusY: DEFAULT_FOCUS.cy,
+ progress: 0,
+ x: 0,
+ y: 0,
+ };
+}
interface VideoPlaybackProps {
videoPath: string;
@@ -29,18 +60,26 @@ interface VideoPlaybackProps {
isPlaying: boolean;
showShadow?: boolean;
shadowIntensity?: number;
- showBlur?: boolean;
- motionBlurEnabled?: boolean;
+ backgroundBlur?: number;
+ zoomMotionBlur?: number;
+ connectZooms?: boolean;
borderRadius?: number;
padding?: number;
cropRegion?: import('./types').CropRegion;
trimRegions?: TrimRegion[];
+ speedRegions?: SpeedRegion[];
aspectRatio: AspectRatio;
annotationRegions?: AnnotationRegion[];
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void;
onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void;
+ cursorTelemetry?: CursorTelemetryPoint[];
+ showCursor?: boolean;
+ cursorSize?: number;
+ cursorSmoothing?: number;
+ cursorMotionBlur?: number;
+ cursorClickBounce?: number;
}
export interface VideoPlaybackRef {
@@ -51,6 +90,7 @@ export interface VideoPlaybackRef {
containerRef: React.RefObject;
play: () => Promise;
pause: () => void;
+ refreshFrame: () => Promise;
}
const VideoPlayback = forwardRef(({
@@ -68,24 +108,33 @@ const VideoPlayback = forwardRef(({
isPlaying,
showShadow,
shadowIntensity = 0,
- showBlur,
- motionBlurEnabled = false,
+ backgroundBlur = 0,
+ zoomMotionBlur = 0,
+ connectZooms = true,
borderRadius = 0,
padding = 50,
cropRegion,
trimRegions = [],
+ speedRegions = [],
aspectRatio,
annotationRegions = [],
selectedAnnotationId,
onSelectAnnotation,
onAnnotationPositionChange,
onAnnotationSizeChange,
+ cursorTelemetry = [],
+ showCursor = false,
+ cursorSize = DEFAULT_CURSOR_SIZE,
+ cursorSmoothing = DEFAULT_CURSOR_SMOOTHING,
+ cursorMotionBlur = DEFAULT_CURSOR_MOTION_BLUR,
+ cursorClickBounce = DEFAULT_CURSOR_CLICK_BOUNCE,
}, ref) => {
const videoRef = useRef(null);
const containerRef = useRef(null);
const appRef = useRef(null);
const videoSpriteRef = useRef(null);
const videoContainerRef = useRef(null);
+ const cursorContainerRef = useRef(null);
const cameraContainerRef = useRef(null);
const timeUpdateAnimationRef = useRef(null);
const [pixiReady, setPixiReady] = useState(false);
@@ -95,8 +144,9 @@ const VideoPlayback = forwardRef(({
const currentTimeRef = useRef(0);
const zoomRegionsRef = useRef([]);
const selectedZoomIdRef = useRef(null);
- const animationStateRef = useRef({ scale: 1, focusX: DEFAULT_FOCUS.cx, focusY: DEFAULT_FOCUS.cy });
+ const animationStateRef = useRef(createPlaybackAnimationState());
const blurFilterRef = useRef(null);
+ const motionBlurFilterRef = useRef(null);
const isDraggingFocusRef = useRef(false);
const stageSizeRef = useRef({ width: 0, height: 0 });
const videoSizeRef = useRef({ width: 0, height: 0 });
@@ -111,8 +161,18 @@ const VideoPlayback = forwardRef(({
const lockedVideoDimensionsRef = useRef<{ width: number; height: number } | null>(null);
const layoutVideoContentRef = useRef<(() => void) | null>(null);
const trimRegionsRef = useRef([]);
- const motionBlurEnabledRef = useRef(motionBlurEnabled);
+ const speedRegionsRef = useRef([]);
+ const zoomMotionBlurRef = useRef(zoomMotionBlur);
+ const connectZoomsRef = useRef(connectZooms);
const videoReadyRafRef = useRef(null);
+ const cursorOverlayRef = useRef(null);
+ const cursorTelemetryRef = useRef([]);
+ const showCursorRef = useRef(showCursor);
+ const cursorSizeRef = useRef(cursorSize);
+ const cursorSmoothingRef = useRef(cursorSmoothing);
+ const cursorMotionBlurRef = useRef(cursorMotionBlur);
+ const cursorClickBounceRef = useRef(cursorClickBounce);
+ const motionBlurStateRef = useRef(createMotionBlurState());
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
@@ -138,8 +198,7 @@ const VideoPlayback = forwardRef(({
indicatorEl,
region,
focusOverride,
- videoSize: videoSizeRef.current,
- baseScale: baseScaleRef.current,
+ baseMask: baseMaskRef.current,
isPlaying: isPlayingRef.current,
});
}, []);
@@ -231,6 +290,40 @@ const VideoPlayback = forwardRef(({
}
video.pause();
},
+ refreshFrame: async () => {
+ const video = videoRef.current;
+ if (!video || Number.isNaN(video.currentTime)) {
+ return;
+ }
+
+ const restoreTime = video.currentTime;
+ const duration = Number.isFinite(video.duration) ? video.duration : 0;
+ const epsilon = duration > 0 ? Math.min(1 / 120, duration / 1000 || 1 / 120) : 1 / 120;
+ const nudgeTarget = restoreTime > epsilon
+ ? restoreTime - epsilon
+ : Math.min(duration || restoreTime + epsilon, restoreTime + epsilon);
+
+ if (Math.abs(nudgeTarget - restoreTime) < 0.000001) {
+ return;
+ }
+
+ await new Promise((resolve) => {
+ const handleFirstSeeked = () => {
+ video.removeEventListener('seeked', handleFirstSeeked);
+ const handleSecondSeeked = () => {
+ video.removeEventListener('seeked', handleSecondSeeked);
+ video.pause();
+ resolve();
+ };
+
+ video.addEventListener('seeked', handleSecondSeeked, { once: true });
+ video.currentTime = restoreTime;
+ };
+
+ video.addEventListener('seeked', handleFirstSeeked, { once: true });
+ video.currentTime = nudgeTarget;
+ });
+ },
}));
const updateFocusFromClientPoint = (clientX: number, clientY: number) => {
@@ -255,10 +348,11 @@ const VideoPlayback = forwardRef(({
const localX = clientX - rect.left;
const localY = clientY - rect.top;
+ const baseMask = baseMaskRef.current;
const unclampedFocus: ZoomFocus = {
- cx: clamp01(localX / stageWidth),
- cy: clamp01(localY / stageHeight),
+ cx: clamp01((localX - baseMask.x) / Math.max(1, baseMask.width)),
+ cy: clamp01((localY - baseMask.y) / Math.max(1, baseMask.height)),
};
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
@@ -320,8 +414,40 @@ const VideoPlayback = forwardRef(({
}, [trimRegions]);
useEffect(() => {
- motionBlurEnabledRef.current = motionBlurEnabled;
- }, [motionBlurEnabled]);
+ speedRegionsRef.current = speedRegions;
+ }, [speedRegions]);
+
+ useEffect(() => {
+ zoomMotionBlurRef.current = zoomMotionBlur;
+ }, [zoomMotionBlur]);
+
+ useEffect(() => {
+ connectZoomsRef.current = connectZooms;
+ }, [connectZooms]);
+
+ useEffect(() => {
+ cursorTelemetryRef.current = cursorTelemetry;
+ }, [cursorTelemetry]);
+
+ useEffect(() => {
+ showCursorRef.current = showCursor;
+ }, [showCursor]);
+
+ useEffect(() => {
+ cursorSizeRef.current = cursorSize;
+ }, [cursorSize]);
+
+ useEffect(() => {
+ cursorSmoothingRef.current = cursorSmoothing;
+ }, [cursorSmoothing]);
+
+ useEffect(() => {
+ cursorMotionBlurRef.current = cursorMotionBlur;
+ }, [cursorMotionBlur]);
+
+ useEffect(() => {
+ cursorClickBounceRef.current = cursorClickBounce;
+ }, [cursorClickBounce]);
useEffect(() => {
if (!pixiReady || !videoReady) return;
@@ -342,11 +468,13 @@ const VideoPlayback = forwardRef(({
video.pause();
}
- animationStateRef.current = {
- scale: 1,
- focusX: DEFAULT_FOCUS.cx,
- focusY: DEFAULT_FOCUS.cy,
- };
+ animationStateRef.current = createPlaybackAnimationState();
+
+ // Reset cursor overlay smoothing on layout change
+ cursorOverlayRef.current?.reset();
+
+ // Reset motion blur state for clean transitions
+ motionBlurStateRef.current = createMotionBlurState();
if (blurFilterRef.current) {
blurFilterRef.current.blur = 0;
@@ -380,7 +508,7 @@ const VideoPlayback = forwardRef(({
focusY: DEFAULT_FOCUS.cy,
motionIntensity: 0,
isPlaying: false,
- motionBlurEnabled: motionBlurEnabledRef.current,
+ motionBlurAmount: zoomMotionBlurRef.current,
});
requestAnimationFrame(() => {
@@ -440,6 +568,14 @@ const VideoPlayback = forwardRef(({
let app: Application | null = null;
(async () => {
+ let cursorOverlayEnabled = true;
+ try {
+ await preloadCursorAssets();
+ } catch (error) {
+ cursorOverlayEnabled = false;
+ console.warn('Native cursor assets are unavailable in preview; continuing without cursor overlay.', error);
+ }
+
app = new Application();
await app.init({
@@ -454,7 +590,7 @@ const VideoPlayback = forwardRef(({
app.ticker.maxFPS = 60;
if (!mounted) {
- app.destroy(true, { children: true, texture: true, textureSource: true });
+ app.destroy(true, { children: true, texture: false, textureSource: false });
return;
}
@@ -470,22 +606,49 @@ const VideoPlayback = forwardRef(({
const videoContainer = new Container();
videoContainerRef.current = videoContainer;
cameraContainer.addChild(videoContainer);
+
+ const cursorContainer = new Container();
+ cursorContainerRef.current = cursorContainer;
+ cameraContainer.addChild(cursorContainer);
+
+ // Cursor overlay - rendered above the masked video so it can sit in front
+ // of the content without getting clipped.
+ if (cursorOverlayEnabled) {
+ const cursorOverlay = new PixiCursorOverlay({
+ dotRadius: DEFAULT_CURSOR_CONFIG.dotRadius * cursorSizeRef.current,
+ smoothingFactor: cursorSmoothingRef.current,
+ motionBlur: cursorMotionBlurRef.current,
+ clickBounce: cursorClickBounceRef.current,
+ });
+ cursorOverlayRef.current = cursorOverlay;
+ cursorContainer.addChild(cursorOverlay.container);
+ } else {
+ cursorOverlayRef.current = null;
+ }
setPixiReady(true);
- })();
+ })().catch((error) => {
+ console.error('Failed to initialize preview renderer:', error);
+ onError(error instanceof Error ? error.message : 'Failed to initialize preview renderer');
+ });
return () => {
mounted = false;
setPixiReady(false);
+ if (cursorOverlayRef.current) {
+ cursorOverlayRef.current.destroy();
+ cursorOverlayRef.current = null;
+ }
if (app && app.renderer) {
- app.destroy(true, { children: true, texture: true, textureSource: true });
+ app.destroy(true, { children: true, texture: false, textureSource: false });
}
appRef.current = null;
cameraContainerRef.current = null;
videoContainerRef.current = null;
+ cursorContainerRef.current = null;
videoSpriteRef.current = null;
};
- }, []);
+ }, [onError]);
useEffect(() => {
const video = videoRef.current;
@@ -509,8 +672,9 @@ const VideoPlayback = forwardRef(({
const video = videoRef.current;
const app = appRef.current;
const videoContainer = videoContainerRef.current;
+ const cursorContainer = cursorContainerRef.current;
- if (!video || !app || !videoContainer) return;
+ if (!video || !app || !videoContainer || !cursorContainer) return;
if (video.videoWidth === 0 || video.videoHeight === 0) return;
const source = VideoSource.from(video);
@@ -530,19 +694,20 @@ const VideoPlayback = forwardRef(({
videoContainer.addChild(maskGraphics);
videoContainer.mask = maskGraphics;
maskGraphicsRef.current = maskGraphics;
+ if (cursorOverlayRef.current) {
+ cursorContainer.addChild(cursorOverlayRef.current.container);
+ }
- animationStateRef.current = {
- scale: 1,
- focusX: DEFAULT_FOCUS.cx,
- focusY: DEFAULT_FOCUS.cy,
- };
+ animationStateRef.current = createPlaybackAnimationState();
const blurFilter = new BlurFilter();
blurFilter.quality = 3;
blurFilter.resolution = app.renderer.resolution;
blurFilter.blur = 0;
- videoContainer.filters = [blurFilter];
+ const motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
+ videoContainer.filters = [blurFilter, motionBlurFilter];
blurFilterRef.current = blurFilter;
+ motionBlurFilterRef.current = motionBlurFilter;
layoutVideoContent();
video.pause();
@@ -557,6 +722,7 @@ const VideoPlayback = forwardRef(({
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
+ speedRegionsRef,
});
video.addEventListener('play', handlePlay);
@@ -591,7 +757,11 @@ const VideoPlayback = forwardRef(({
blurFilterRef.current.destroy();
blurFilterRef.current = null;
}
- videoTexture.destroy(true);
+ if (motionBlurFilterRef.current) {
+ motionBlurFilterRef.current.destroy();
+ motionBlurFilterRef.current = null;
+ }
+ videoTexture.destroy(false);
videoSpriteRef.current = null;
};
@@ -605,32 +775,54 @@ const VideoPlayback = forwardRef(({
const videoContainer = videoContainerRef.current;
if (!app || !videoSprite || !videoContainer) return;
- const applyTransform = (motionIntensity: number) => {
+ const applyTransform = (
+ transform: { scale: number; x: number; y: number },
+ focus: ZoomFocus,
+ motionIntensity: number,
+ motionVector: { x: number; y: number },
+ ) => {
const cameraContainer = cameraContainerRef.current;
if (!cameraContainer) return;
const state = animationStateRef.current;
- applyZoomTransform({
+ const appliedTransform = applyZoomTransform({
cameraContainer,
blurFilter: blurFilterRef.current,
stageSize: stageSizeRef.current,
baseMask: baseMaskRef.current,
zoomScale: state.scale,
- focusX: state.focusX,
- focusY: state.focusY,
+ zoomProgress: state.progress,
+ focusX: focus.cx,
+ focusY: focus.cy,
motionIntensity,
+ motionVector,
isPlaying: isPlayingRef.current,
- motionBlurEnabled: motionBlurEnabledRef.current,
+ motionBlurAmount: zoomMotionBlurRef.current,
+ motionBlurFilter: motionBlurFilterRef.current,
+ transformOverride: transform,
+ motionBlurState: motionBlurStateRef.current,
+ frameTimeMs: performance.now(),
});
+
+ state.x = appliedTransform.x;
+ state.y = appliedTransform.y;
+ state.appliedScale = appliedTransform.scale;
};
const ticker = () => {
- const { region, strength } = findDominantRegion(zoomRegionsRef.current, currentTimeRef.current);
+ const { region, strength, blendedScale, transition } = findDominantRegion(
+ zoomRegionsRef.current,
+ currentTimeRef.current,
+ {
+ connectZooms: connectZoomsRef.current,
+ },
+ );
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = defaultFocus;
+ let targetProgress = 0;
// If a zoom is selected but video is not playing, show default unzoomed view
// (the overlay will show where the zoom will be)
@@ -639,60 +831,103 @@ const VideoPlayback = forwardRef(({
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
if (region && strength > 0 && !shouldShowUnzoomedView) {
- const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
- const regionFocus = clampFocusToStage(region.focus, region.depth);
-
- // Interpolate scale and focus based on region strength
- targetScaleFactor = 1 + (zoomScale - 1) * strength;
- targetFocus = {
- cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
- cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
- };
+ const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
+ const regionFocus = region.focus;
+
+ targetScaleFactor = zoomScale;
+ targetFocus = regionFocus;
+ targetProgress = strength;
+
+ if (transition) {
+ const startTransform = computeZoomTransform({
+ stageSize: stageSizeRef.current,
+ baseMask: baseMaskRef.current,
+ zoomScale: transition.startScale,
+ zoomProgress: 1,
+ focusX: transition.startFocus.cx,
+ focusY: transition.startFocus.cy,
+ });
+ const endTransform = computeZoomTransform({
+ stageSize: stageSizeRef.current,
+ baseMask: baseMaskRef.current,
+ zoomScale: transition.endScale,
+ zoomProgress: 1,
+ focusX: transition.endFocus.cx,
+ focusY: transition.endFocus.cy,
+ });
+
+ 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,
+ };
+
+ targetScaleFactor = interpolatedTransform.scale;
+ targetFocus = computeFocusFromTransform({
+ stageSize: stageSizeRef.current,
+ baseMask: baseMaskRef.current,
+ zoomScale: interpolatedTransform.scale,
+ x: interpolatedTransform.x,
+ y: interpolatedTransform.y,
+ });
+ targetProgress = 1;
+ }
}
const state = animationStateRef.current;
+ const prevScale = state.appliedScale;
+ const prevX = state.x;
+ const prevY = state.y;
- const prevScale = state.scale;
- const prevFocusX = state.focusX;
- const prevFocusY = state.focusY;
+ state.scale = targetScaleFactor;
+ state.focusX = targetFocus.cx;
+ state.focusY = targetFocus.cy;
+ state.progress = targetProgress;
- const scaleDelta = targetScaleFactor - state.scale;
- const focusXDelta = targetFocus.cx - state.focusX;
- const focusYDelta = targetFocus.cy - state.focusY;
+ const projectedTransform = computeZoomTransform({
+ stageSize: stageSizeRef.current,
+ baseMask: baseMaskRef.current,
+ zoomScale: state.scale,
+ zoomProgress: state.progress,
+ focusX: state.focusX,
+ focusY: state.focusY,
+ });
- let nextScale = prevScale;
- let nextFocusX = prevFocusX;
- let nextFocusY = prevFocusY;
-
- if (Math.abs(scaleDelta) > MIN_DELTA) {
- nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
- } else {
- nextScale = targetScaleFactor;
- }
-
- if (Math.abs(focusXDelta) > MIN_DELTA) {
- nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
- } else {
- nextFocusX = targetFocus.cx;
- }
-
- if (Math.abs(focusYDelta) > MIN_DELTA) {
- nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
- } else {
- nextFocusY = targetFocus.cy;
- }
-
- state.scale = nextScale;
- state.focusX = nextFocusX;
- state.focusY = nextFocusY;
+ const appliedScale = Math.abs(projectedTransform.scale - prevScale) < ZOOM_SCALE_DEADZONE
+ ? projectedTransform.scale
+ : projectedTransform.scale;
+ const appliedX = Math.abs(projectedTransform.x - prevX) < ZOOM_TRANSLATION_DEADZONE_PX
+ ? projectedTransform.x
+ : projectedTransform.x;
+ const appliedY = Math.abs(projectedTransform.y - prevY) < ZOOM_TRANSLATION_DEADZONE_PX
+ ? projectedTransform.y
+ : projectedTransform.y;
const motionIntensity = Math.max(
- Math.abs(nextScale - prevScale),
- Math.abs(nextFocusX - prevFocusX),
- Math.abs(nextFocusY - prevFocusY)
+ Math.abs(appliedScale - prevScale),
+ Math.abs(appliedX - prevX) / Math.max(1, stageSizeRef.current.width),
+ Math.abs(appliedY - prevY) / Math.max(1, stageSizeRef.current.height),
);
- applyTransform(motionIntensity);
+ const motionVector = {
+ x: appliedX - prevX,
+ y: appliedY - prevY,
+ };
+
+ applyTransform({ scale: appliedScale, x: appliedX, y: appliedY }, targetFocus, motionIntensity, motionVector);
+
+ // Update cursor overlay
+ const cursorOverlay = cursorOverlayRef.current;
+ if (cursorOverlay) {
+ const timeMs = currentTimeRef.current;
+ cursorOverlay.update(
+ cursorTelemetryRef.current,
+ timeMs,
+ baseMaskRef.current,
+ showCursorRef.current,
+ !isPlayingRef.current || isSeekingRef.current,
+ );
+ }
};
app.ticker.add(ticker);
@@ -703,6 +938,19 @@ const VideoPlayback = forwardRef(({
};
}, [pixiReady, videoReady, clampFocusToStage]);
+ useEffect(() => {
+ const overlay = cursorOverlayRef.current;
+ if (!overlay) {
+ return;
+ }
+
+ overlay.setDotRadius(DEFAULT_CURSOR_CONFIG.dotRadius * cursorSize);
+ overlay.setSmoothingFactor(cursorSmoothing);
+ overlay.setMotionBlur(cursorMotionBlur);
+ overlay.setClickBounce(cursorClickBounce);
+ overlay.reset();
+ }, [cursorSize, cursorSmoothing, cursorMotionBlur, cursorClickBounce]);
+
const handleLoadedMetadata = (e: React.SyntheticEvent) => {
const video = e.currentTarget;
onDurationChange(video.duration);
@@ -737,7 +985,7 @@ const VideoPlayback = forwardRef(({
;(async () => {
try {
if (!wallpaper) {
- const def = await getAssetPath('wallpapers/wallpaper1.jpg')
+ const def = await getAssetPath(DEFAULT_WALLPAPER_RELATIVE_PATH)
if (mounted) setResolvedWallpaper(def)
return
}
@@ -753,22 +1001,15 @@ const VideoPlayback = forwardRef(({
return
}
- // If it's an absolute web/http or file path, use as-is
if (wallpaper.startsWith('http') || wallpaper.startsWith('file://') || wallpaper.startsWith('/')) {
- // If it's an absolute server path (starts with '/'), resolve via getAssetPath as well
- if (wallpaper.startsWith('/')) {
- const rel = wallpaper.replace(/^\//, '')
- const p = await getAssetPath(rel)
- if (mounted) setResolvedWallpaper(p)
- return
- }
- if (mounted) setResolvedWallpaper(wallpaper)
+ const renderable = await getRenderableAssetUrl(wallpaper)
+ if (mounted) setResolvedWallpaper(renderable)
return
}
- const p = await getAssetPath(wallpaper.replace(/^\//, ''))
+ const p = await getRenderableAssetUrl(await getAssetPath(wallpaper.replace(/^\//, '')))
if (mounted) setResolvedWallpaper(p)
} catch (err) {
- if (mounted) setResolvedWallpaper(wallpaper || '/wallpapers/wallpaper1.jpg')
+ if (mounted) setResolvedWallpaper(wallpaper || DEFAULT_WALLPAPER_PATH)
}
})()
return () => { mounted = false }
@@ -795,7 +1036,7 @@ const VideoPlayback = forwardRef(({
className="absolute inset-0 bg-cover bg-center"
style={{
...backgroundStyle,
- filter: showBlur ? 'blur(2px)' : 'none',
+ filter: backgroundBlur > 0 ? `blur(${backgroundBlur}px)` : 'none',
}}
/>
(({
>
{(() => {
diff --git a/src/components/video-editor/projectPersistence.ts b/src/components/video-editor/projectPersistence.ts
new file mode 100644
index 00000000..c3633510
--- /dev/null
+++ b/src/components/video-editor/projectPersistence.ts
@@ -0,0 +1,309 @@
+import { ASPECT_RATIOS, type AspectRatio } from "@/utils/aspectRatioUtils";
+import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
+import { WALLPAPER_PATHS } from "@/lib/wallpapers";
+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 ZoomRegion,
+} from "./types";
+
+export const PROJECT_VERSION = 1;
+
+export interface ProjectEditorState {
+ wallpaper: string;
+ shadowIntensity: number;
+ backgroundBlur: number;
+ zoomMotionBlur: number;
+ connectZooms: boolean;
+ showCursor: boolean;
+ cursorSize: number;
+ cursorSmoothing: number;
+ cursorMotionBlur: number;
+ cursorClickBounce: number;
+ borderRadius: number;
+ padding: number;
+ cropRegion: CropRegion;
+ zoomRegions: ZoomRegion[];
+ trimRegions: TrimRegion[];
+ speedRegions: SpeedRegion[];
+ annotationRegions: AnnotationRegion[];
+ aspectRatio: AspectRatio;
+ exportQuality: ExportQuality;
+ exportFormat: ExportFormat;
+ gifFrameRate: GifFrameRate;
+ gifLoop: boolean;
+ gifSizePreset: GifSizePreset;
+}
+
+export interface EditorProjectData {
+ version: number;
+ videoPath: string;
+ editor: ProjectEditorState;
+}
+
+function isFiniteNumber(value: unknown): value is number {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+export function toFileUrl(filePath: string): string {
+ const normalized = filePath.replace(/\\/g, "/");
+ if (normalized.match(/^[a-zA-Z]:/)) {
+ return `file:///${normalized}`;
+ }
+ return `file://${normalized}`;
+}
+
+export function fromFileUrl(fileUrl: string): string {
+ if (!fileUrl.startsWith("file://")) {
+ return fileUrl;
+ }
+
+ try {
+ const url = new URL(fileUrl);
+ return decodeURIComponent(url.pathname);
+ } catch {
+ return fileUrl.replace(/^file:\/\//, "");
+ }
+}
+
+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;
+}
+
+export function validateProjectData(candidate: unknown): candidate is EditorProjectData {
+ if (!candidate || typeof candidate !== "object") return false;
+ const project = candidate as Partial
;
+ 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 {
+ const validAspectRatios = new Set(ASPECT_RATIOS);
+ const legacyMotionBlurEnabled = (editor as Partial<{ motionBlurEnabled: boolean }>).motionBlurEnabled;
+ const legacyShowBlur = (editor as Partial<{ showBlur: boolean }>).showBlur;
+ const normalizedZoomMotionBlur = isFiniteNumber((editor as Partial).zoomMotionBlur)
+ ? clamp((editor as Partial).zoomMotionBlur as number, 0, 2)
+ : legacyMotionBlurEnabled
+ ? 0.35
+ : DEFAULT_ZOOM_MOTION_BLUR;
+ const normalizedBackgroundBlur = isFiniteNumber((editor as Partial).backgroundBlur)
+ ? clamp((editor as Partial).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);
+
+ 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 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;
+
+ 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);
+
+ 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 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);
+
+ 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,
+ 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).cursorMotionBlur)
+ ? clamp((editor as Partial).cursorMotionBlur as number, 0, 2)
+ : DEFAULT_CURSOR_MOTION_BLUR,
+ cursorClickBounce: isFiniteNumber((editor as Partial).cursorClickBounce)
+ ? clamp((editor as Partial).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,
+ aspectRatio: editor.aspectRatio && validAspectRatios.has(editor.aspectRatio) ? editor.aspectRatio : "16:9",
+ exportQuality: editor.exportQuality === "medium" || 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,
+ };
+}
diff --git a/src/components/video-editor/timeline/Item.tsx b/src/components/video-editor/timeline/Item.tsx
index ed5fc8b8..da2d46e8 100644
--- a/src/components/video-editor/timeline/Item.tsx
+++ b/src/components/video-editor/timeline/Item.tsx
@@ -2,7 +2,7 @@ import { useMemo } from "react";
import { useItem } from "dnd-timeline";
import type { Span } from "dnd-timeline";
import { cn } from "@/lib/utils";
-import { ZoomIn, Scissors, MessageSquare } from "lucide-react";
+import { ZoomIn, Scissors, MessageSquare, Gauge } from "lucide-react";
import glassStyles from "./ItemGlass.module.css";
interface ItemProps {
@@ -13,7 +13,8 @@ interface ItemProps {
isSelected?: boolean;
onSelect?: () => void;
zoomDepth?: number;
- variant?: 'zoom' | 'trim' | 'annotation';
+ speedValue?: number;
+ variant?: 'zoom' | 'trim' | 'annotation' | 'speed';
}
// Map zoom depth to multiplier labels
@@ -36,13 +37,14 @@ function formatMs(ms: number): string {
return `${seconds.toFixed(1)}s`;
}
-export default function Item({
- id,
- span,
- rowId,
- isSelected = false,
- onSelect,
+export default function Item({
+ id,
+ span,
+ rowId,
+ isSelected = false,
+ onSelect,
zoomDepth = 1,
+ speedValue,
variant = 'zoom',
children
}: ItemProps) {
@@ -54,17 +56,22 @@ export default function Item({
const isZoom = variant === 'zoom';
const isTrim = variant === 'trim';
+ const isSpeed = variant === 'speed';
const glassClass = isZoom
? glassStyles.glassGreen
: isTrim
? glassStyles.glassRed
+ : isSpeed
+ ? glassStyles.glassAmber
: glassStyles.glassYellow;
const endCapColor = isZoom
- ? '#21916A'
+ ? '#2563EB'
: isTrim
? '#ef4444'
+ : isSpeed
+ ? '#d97706'
: '#B4A046';
const timeLabel = useMemo(
@@ -72,10 +79,13 @@ export default function Item({
[span.start, span.end],
);
+ const MIN_ITEM_PX = 6;
+ const safeItemStyle = { ...itemStyle, minWidth: MIN_ITEM_PX };
+
return (
onSelect?.()}
@@ -121,6 +131,13 @@ export default function Item({
Trim
>
+ ) : isSpeed ? (
+ <>
+
+
+ {speedValue !== undefined ? `${speedValue}×` : 'Speed'}
+
+ >
) : (
<>
diff --git a/src/components/video-editor/timeline/ItemGlass.module.css b/src/components/video-editor/timeline/ItemGlass.module.css
index d89cc0e2..4e8ce350 100644
--- a/src/components/video-editor/timeline/ItemGlass.module.css
+++ b/src/components/video-editor/timeline/ItemGlass.module.css
@@ -2,9 +2,9 @@
position: relative;
border-radius: 8px;
-corner-smoothing: antialiased;
- background: rgba(52, 178, 123, 0.15);
- border: 1px solid rgba(52, 178, 123, 0.3);
- box-shadow: 0 2px 12px 0 rgba(52, 178, 123, 0.1) inset;
+ background: rgba(37, 99, 235, 0.15);
+ border: 1px solid rgba(37, 99, 235, 0.3);
+ box-shadow: 0 2px 12px 0 rgba(37, 99, 235, 0.1) inset;
margin: 2px 0;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
@@ -12,15 +12,15 @@
}
.glassGreen:hover {
- background: rgba(52, 178, 123, 0.25);
- border-color: rgba(52, 178, 123, 0.5);
- box-shadow: 0 4px 20px 0 rgba(52, 178, 123, 0.2) inset;
+ background: rgba(37, 99, 235, 0.25);
+ border-color: rgba(37, 99, 235, 0.5);
+ box-shadow: 0 4px 20px 0 rgba(37, 99, 235, 0.2) inset;
}
.glassGreen.selected {
- background: rgba(52, 178, 123, 0.35);
- border-color: #34B27B;
- box-shadow: 0 0 0 1px #34B27B, 0 4px 20px 0 rgba(52, 178, 123, 0.3) inset;
+ background: rgba(37, 99, 235, 0.35);
+ border-color: #2563EB;
+ box-shadow: 0 0 0 1px #2563EB, 0 4px 20px 0 rgba(37, 99, 235, 0.3) inset;
z-index: 10;
}
@@ -76,6 +76,32 @@
z-index: 10;
}
+.glassAmber {
+ position: relative;
+ border-radius: 8px;
+ -corner-smoothing: antialiased;
+ background: rgba(245, 158, 11, 0.15);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ box-shadow: 0 2px 12px 0 rgba(245, 158, 11, 0.1) inset;
+ margin: 2px 0;
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+}
+
+.glassAmber:hover {
+ background: rgba(245, 158, 11, 0.25);
+ border-color: rgba(245, 158, 11, 0.5);
+ box-shadow: 0 4px 20px 0 rgba(245, 158, 11, 0.2) inset;
+}
+
+.glassAmber.selected {
+ background: rgba(245, 158, 11, 0.35);
+ border-color: #f59e0b;
+ box-shadow: 0 0 0 1px #f59e0b, 0 4px 20px 0 rgba(245, 158, 11, 0.3) inset;
+ z-index: 10;
+}
+
.zoomEndCap {
position: absolute;
top: 0;
@@ -92,7 +118,9 @@
.glassRed:hover .zoomEndCap,
.glassRed.selected .zoomEndCap,
.glassYellow:hover .zoomEndCap,
-.glassYellow.selected .zoomEndCap {
+.glassYellow.selected .zoomEndCap,
+.glassAmber:hover .zoomEndCap,
+.glassAmber.selected .zoomEndCap {
opacity: 1;
}
diff --git a/src/components/video-editor/timeline/KeyframeMarkers.tsx b/src/components/video-editor/timeline/KeyframeMarkers.tsx
index ed0bbfc0..c6be2634 100644
--- a/src/components/video-editor/timeline/KeyframeMarkers.tsx
+++ b/src/components/video-editor/timeline/KeyframeMarkers.tsx
@@ -67,7 +67,7 @@ const KeyframeMarkers: React.FC
= ({
return (
void;
+ cursorTelemetry?: CursorTelemetryPoint[];
zoomRegions: ZoomRegion[];
onZoomAdded: (span: Span) => void;
+ onZoomSuggested?: (span: Span, focus: ZoomFocus) => void;
onZoomSpanChange: (id: string, span: Span) => void;
onZoomDelete: (id: string) => void;
selectedZoomId: string | null;
@@ -49,13 +56,17 @@ interface TimelineEditorProps {
onAnnotationDelete?: (id: string) => void;
selectedAnnotationId?: string | null;
onSelectAnnotation?: (id: string | null) => void;
+ speedRegions?: SpeedRegion[];
+ onSpeedAdded?: (span: Span) => void;
+ onSpeedSpanChange?: (id: string, span: Span) => void;
+ onSpeedDelete?: (id: string) => void;
+ selectedSpeedId?: string | null;
+ onSelectSpeed?: (id: string | null) => void;
aspectRatio: AspectRatio;
onAspectRatioChange: (aspectRatio: AspectRatio) => void;
}
interface TimelineScaleConfig {
- intervalMs: number;
- gridMs: number;
minItemDurationMs: number;
defaultItemDurationMs: number;
minVisibleRangeMs: number;
@@ -67,10 +78,13 @@ interface TimelineRenderItem {
span: Span;
label: string;
zoomDepth?: number;
- variant: 'zoom' | 'trim' | 'annotation';
+ speedValue?: number;
+ variant: 'zoom' | 'trim' | 'annotation' | 'speed';
}
const SCALE_CANDIDATES = [
+ { intervalSeconds: 0.05, gridSeconds: 0.01 },
+ { intervalSeconds: 0.1, gridSeconds: 0.02 },
{ intervalSeconds: 0.25, gridSeconds: 0.05 },
{ intervalSeconds: 0.5, gridSeconds: 0.1 },
{ intervalSeconds: 1, gridSeconds: 0.25 },
@@ -88,34 +102,34 @@ const SCALE_CANDIDATES = [
{ intervalSeconds: 3600, gridSeconds: 300 },
];
+function calculateAxisScale(visibleRangeMs: number): { intervalMs: number; gridMs: number } {
+ const visibleSeconds = visibleRangeMs / 1000;
+ const candidate =
+ SCALE_CANDIDATES.find((scaleCandidate) => {
+ if (visibleSeconds <= 0) {
+ return true;
+ }
+ return visibleSeconds / scaleCandidate.intervalSeconds <= TARGET_MARKER_COUNT;
+ }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
+
+ return {
+ intervalMs: Math.round(candidate.intervalSeconds * 1000),
+ gridMs: Math.round(candidate.gridSeconds * 1000),
+ };
+}
+
function calculateTimelineScale(durationSeconds: number): TimelineScaleConfig {
const totalMs = Math.max(0, Math.round(durationSeconds * 1000));
- const selectedCandidate = SCALE_CANDIDATES.find((candidate) => {
- if (durationSeconds <= 0) {
- return true;
- }
- const markers = durationSeconds / candidate.intervalSeconds;
- return markers <= TARGET_MARKER_COUNT;
- }) ?? SCALE_CANDIDATES[SCALE_CANDIDATES.length - 1];
+ const minItemDurationMs = 100;
- const intervalMs = Math.round(selectedCandidate.intervalSeconds * 1000);
- const gridMs = Math.round(selectedCandidate.gridSeconds * 1000);
+ const defaultItemDurationMs = totalMs > 0
+ ? Math.max(minItemDurationMs, Math.min(Math.round(totalMs * 0.05), 30000))
+ : Math.max(minItemDurationMs, 1000);
- // Set minItemDurationMs to 1ms for maximum granularity
- const minItemDurationMs = 1;
- const defaultItemDurationMs = Math.min(
- Math.max(minItemDurationMs, intervalMs * 2),
- totalMs > 0 ? totalMs : intervalMs * 2,
- );
-
- const minVisibleRangeMs = totalMs > 0
- ? Math.min(Math.max(intervalMs * 3, minItemDurationMs * 6, 1000), totalMs)
- : Math.max(intervalMs * 3, minItemDurationMs * 6, 1000);
+ const minVisibleRangeMs = 300;
return {
- intervalMs,
- gridMs,
minItemDurationMs,
defaultItemDurationMs,
minVisibleRangeMs,
@@ -130,6 +144,18 @@ function createInitialRange(totalMs: number): Range {
return { start: 0, end: FALLBACK_RANGE_MS };
}
+function normalizeWheelDeltaToPixels(delta: number, deltaMode: number) {
+ if (deltaMode === 1) {
+ return delta * 16;
+ }
+
+ if (deltaMode === 2) {
+ return delta * 240;
+ }
+
+ return delta;
+}
+
function formatTimeLabel(milliseconds: number, intervalMs: number) {
const totalSeconds = milliseconds / 1000;
const hours = Math.floor(totalSeconds / 3600);
@@ -245,7 +271,7 @@ function PlaybackCursor({
}}
>
{isDragging && (
@@ -271,17 +297,20 @@ function PlaybackCursor({
}
function TimelineAxis({
- intervalMs,
videoDurationMs,
currentTimeMs,
}: {
- intervalMs: number;
videoDurationMs: number;
currentTimeMs: number;
}) {
const { sidebarWidth, direction, range, valueToPixels } = useTimelineContext();
const sideProperty = direction === "rtl" ? "right" : "left";
+ const { intervalMs } = useMemo(
+ () => calculateAxisScale(range.end - range.start),
+ [range.end, range.start],
+ );
+
const markers = useMemo(() => {
if (intervalMs <= 0) {
return { markers: [], minorTicks: [] };
@@ -374,7 +403,7 @@ function TimelineAxis({
{marker.label}
@@ -390,28 +419,30 @@ function TimelineAxis({
function Timeline({
items,
videoDurationMs,
- intervalMs,
currentTimeMs,
onSeek,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
+ onSelectSpeed,
selectedZoomId,
selectedTrimId,
selectedAnnotationId,
+ selectedSpeedId,
keyframes = [],
}: {
items: TimelineRenderItem[];
videoDurationMs: number;
- intervalMs: number;
currentTimeMs: number;
onSeek?: (time: number) => void;
onSelectZoom?: (id: string | null) => void;
onSelectTrim?: (id: string | null) => void;
onSelectAnnotation?: (id: string | null) => void;
+ onSelectSpeed?: (id: string | null) => void;
selectedZoomId: string | null;
selectedTrimId?: string | null;
selectedAnnotationId?: string | null;
+ selectedSpeedId?: string | null;
keyframes?: { id: string; time: number }[];
}) {
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
@@ -430,6 +461,7 @@ function Timeline({
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
+ onSelectSpeed?.(null);
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
@@ -441,11 +473,12 @@ function Timeline({
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
- }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
+ }, [onSeek, onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectSpeed, videoDurationMs, sidebarWidth, range.start, pixelsToValue]);
const zoomItems = items.filter(item => item.rowId === ZOOM_ROW_ID);
const trimItems = items.filter(item => item.rowId === TRIM_ROW_ID);
const annotationItems = items.filter(item => item.rowId === ANNOTATION_ROW_ID);
+ const speedItems = items.filter(item => item.rowId === SPEED_ROW_ID);
return (
-
+
))}
+
+
+ {speedItems.map((item) => (
+ - onSelectSpeed?.(item.id)}
+ variant="speed"
+ speedValue={item.speedValue}
+ >
+ {item.label}
+
+ ))}
+
);
}
@@ -520,8 +570,10 @@ export default function TimelineEditor({
videoDuration,
currentTime,
onSeek,
+ cursorTelemetry = [],
zoomRegions,
onZoomAdded,
+ onZoomSuggested,
onZoomSpanChange,
onZoomDelete,
selectedZoomId,
@@ -538,6 +590,12 @@ export default function TimelineEditor({
onAnnotationDelete,
selectedAnnotationId,
onSelectAnnotation,
+ speedRegions = [],
+ onSpeedAdded,
+ onSpeedSpanChange,
+ onSpeedDelete,
+ selectedSpeedId,
+ onSelectSpeed,
aspectRatio,
onAspectRatioChange,
}: TimelineEditorProps) {
@@ -552,16 +610,17 @@ export default function TimelineEditor({
const [range, setRange] = useState(() => createInitialRange(totalMs));
const [keyframes, setKeyframes] = useState<{ id: string; time: number }[]>([]);
const [selectedKeyframeId, setSelectedKeyframeId] = useState(null);
- const [shortcuts, setShortcuts] = useState({
+ const [scrollLabels, setScrollLabels] = useState({
pan: 'Shift + Ctrl + Scroll',
zoom: 'Ctrl + Scroll'
});
const timelineContainerRef = useRef(null);
+ const { shortcuts: keyShortcuts, isMac } = useShortcuts();
useEffect(() => {
formatShortcut(['shift', 'mod', 'Scroll']).then(pan => {
formatShortcut(['mod', 'Scroll']).then(zoom => {
- setShortcuts({ pan, zoom });
+ setScrollLabels({ pan, zoom });
});
});
}, []);
@@ -606,6 +665,12 @@ export default function TimelineEditor({
onSelectAnnotation(null);
}, [selectedAnnotationId, onAnnotationDelete, onSelectAnnotation]);
+ const deleteSelectedSpeed = useCallback(() => {
+ if (!selectedSpeedId || !onSpeedDelete || !onSelectSpeed) return;
+ onSpeedDelete(selectedSpeedId);
+ onSelectSpeed(null);
+ }, [selectedSpeedId, onSpeedDelete, onSelectSpeed]);
+
useEffect(() => {
setRange(createInitialRange(totalMs));
}, [totalMs]);
@@ -615,8 +680,10 @@ export default function TimelineEditor({
// this effect on every drag/resize and races with dnd-timeline's internal state.
const zoomRegionsRef = useRef(zoomRegions);
const trimRegionsRef = useRef(trimRegions);
+ const speedRegionsRef = useRef(speedRegions);
zoomRegionsRef.current = zoomRegions;
trimRegionsRef.current = trimRegions;
+ speedRegionsRef.current = speedRegions;
useEffect(() => {
if (totalMs === 0 || safeMinDurationMs <= 0) {
@@ -646,21 +713,34 @@ export default function TimelineEditor({
onTrimSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
}
});
+
+ speedRegionsRef.current.forEach((region) => {
+ const clampedStart = Math.max(0, Math.min(region.startMs, totalMs));
+ const minEnd = clampedStart + safeMinDurationMs;
+ const clampedEnd = Math.min(totalMs, Math.max(minEnd, region.endMs));
+ const normalizedStart = Math.max(0, Math.min(clampedStart, totalMs - safeMinDurationMs));
+ const normalizedEnd = Math.max(minEnd, Math.min(clampedEnd, totalMs));
+
+ if (normalizedStart !== region.startMs || normalizedEnd !== region.endMs) {
+ onSpeedSpanChange?.(region.id, { start: normalizedStart, end: normalizedEnd });
+ }
+ });
// Only re-run when the timeline scale changes, not on every region edit
- }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange]);
+ }, [totalMs, safeMinDurationMs, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange]);
const hasOverlap = useCallback((newSpan: Span, excludeId?: string): boolean => {
// Determine which row the item belongs to
const isZoomItem = zoomRegions.some(r => r.id === excludeId);
const isTrimItem = trimRegions.some(r => r.id === excludeId);
const isAnnotationItem = annotationRegions.some(r => r.id === excludeId);
+ const isSpeedItem = speedRegions.some(r => r.id === excludeId);
if (isAnnotationItem) {
return false;
}
// Helper to check overlap against a specific set of regions
- const checkOverlap = (regions: (ZoomRegion | TrimRegion)[]) => {
+ const checkOverlap = (regions: (ZoomRegion | TrimRegion | SpeedRegion)[]) => {
return regions.some((region) => {
if (region.id === excludeId) return false;
// True overlap: regions actually intersect (not just adjacent)
@@ -676,13 +756,17 @@ export default function TimelineEditor({
return checkOverlap(trimRegions);
}
- return false;
- }, [zoomRegions, trimRegions, annotationRegions]);
+ if (isSpeedItem) {
+ return checkOverlap(speedRegions);
+ }
- // At least 5% of the timeline or 1000ms, whichever is larger, so the region
- // is always wide enough to grab and resize comfortably.
+ return false;
+ }, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
+
+ // Keep newly added timeline regions at the original short default instead of
+ // scaling them with the full recording length.
const defaultRegionDurationMs = useMemo(
- () => Math.max(1000, Math.round(totalMs * 0.05)),
+ () => Math.min(1000, totalMs),
[totalMs],
);
@@ -716,6 +800,91 @@ export default function TimelineEditor({
onZoomAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, zoomRegions, onZoomAdded, defaultRegionDurationMs]);
+ const handleSuggestZooms = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0) {
+ return;
+ }
+
+ if (!onZoomSuggested) {
+ toast.error("Zoom suggestion handler unavailable");
+ return;
+ }
+
+ if (cursorTelemetry.length < 2) {
+ toast.info("No cursor telemetry available", {
+ description: "Record a screencast first to generate cursor-based suggestions.",
+ });
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ const reservedSpans = [...zoomRegions]
+ .map((region) => ({ start: region.startMs, end: region.endMs }))
+ .sort((a, b) => a.start - b.start);
+
+ const normalizedSamples = normalizeCursorTelemetry(cursorTelemetry, totalMs);
+
+ if (normalizedSamples.length < 2) {
+ toast.info("No usable cursor telemetry", {
+ description: "The recording does not include enough cursor movement data.",
+ });
+ return;
+ }
+
+ const dwellCandidates = detectInteractionCandidates(normalizedSamples);
+
+ if (dwellCandidates.length === 0) {
+ toast.info("No clear interaction moments found", {
+ description: "Try a recording with pauses or clicks around important actions.",
+ });
+ return;
+ }
+
+ const sortedCandidates = [...dwellCandidates].sort((a, b) => b.strength - a.strength);
+ const acceptedCenters: number[] = [];
+
+ let addedCount = 0;
+
+ sortedCandidates.forEach((candidate) => {
+ const tooCloseToAccepted = acceptedCenters.some(
+ (center) => Math.abs(center - candidate.centerTimeMs) < SUGGESTION_SPACING_MS,
+ );
+
+ if (tooCloseToAccepted) {
+ return;
+ }
+
+ const centeredStart = Math.round(candidate.centerTimeMs - defaultDuration / 2);
+ const candidateStart = Math.max(0, Math.min(centeredStart, totalMs - defaultDuration));
+ const candidateEnd = candidateStart + defaultDuration;
+ const hasOverlap = reservedSpans.some(
+ (span) => candidateEnd > span.start && candidateStart < span.end,
+ );
+
+ if (hasOverlap) {
+ return;
+ }
+
+ reservedSpans.push({ start: candidateStart, end: candidateEnd });
+ acceptedCenters.push(candidate.centerTimeMs);
+ onZoomSuggested({ start: candidateStart, end: candidateEnd }, candidate.focus);
+ addedCount += 1;
+ });
+
+ if (addedCount === 0) {
+ toast.info("No auto-zoom slots available", {
+ description: "Detected dwell points overlap existing zoom regions.",
+ });
+ return;
+ }
+
+ toast.success(`Added ${addedCount} interaction-based zoom suggestion${addedCount === 1 ? "" : "s"}`);
+ }, [videoDuration, totalMs, defaultRegionDurationMs, zoomRegions, onZoomSuggested, cursorTelemetry]);
+
const handleAddTrim = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) {
return;
@@ -746,6 +915,36 @@ export default function TimelineEditor({
onTrimAdded({ start: startPos, end: startPos + actualDuration });
}, [videoDuration, totalMs, currentTimeMs, trimRegions, onTrimAdded, defaultRegionDurationMs]);
+ const handleAddSpeed = useCallback(() => {
+ if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onSpeedAdded) {
+ return;
+ }
+
+ const defaultDuration = Math.min(defaultRegionDurationMs, totalMs);
+ if (defaultDuration <= 0) {
+ return;
+ }
+
+ // Always place speed region at playhead
+ const startPos = Math.max(0, Math.min(currentTimeMs, totalMs));
+ // Find the next speed region after the playhead
+ const sorted = [...speedRegions].sort((a, b) => a.startMs - b.startMs);
+ const nextRegion = sorted.find(region => region.startMs > startPos);
+ const gapToNext = nextRegion ? nextRegion.startMs - startPos : totalMs - startPos;
+
+ // Check if playhead is inside any speed region
+ const isOverlapping = sorted.some(region => startPos >= region.startMs && startPos < region.endMs);
+ if (isOverlapping || gapToNext <= 0) {
+ toast.error("Cannot place speed here", {
+ description: "Speed region already exists at this location or not enough space available.",
+ });
+ return;
+ }
+
+ const actualDuration = Math.min(defaultRegionDurationMs, gapToNext);
+ onSpeedAdded({ start: startPos, end: startPos + actualDuration });
+ }, [videoDuration, totalMs, currentTimeMs, speedRegions, onSpeedAdded, defaultRegionDurationMs]);
+
const handleAddAnnotation = useCallback(() => {
if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onAnnotationAdded) {
return;
@@ -769,18 +968,21 @@ export default function TimelineEditor({
return;
}
- if (e.key === 'f' || e.key === 'F') {
+ if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) {
addKeyframe();
}
- if (e.key === 'z' || e.key === 'Z') {
+ if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) {
handleAddZoom();
}
- if (e.key === 't' || e.key === 'T') {
+ if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) {
handleAddTrim();
}
- if (e.key === 'a' || e.key === 'A') {
+ if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) {
handleAddAnnotation();
}
+ if (matchesShortcut(e, keyShortcuts.addSpeed, isMac)) {
+ handleAddSpeed();
+ }
// Tab: Cycle through overlapping annotations at current time
if (e.key === 'Tab' && annotationRegions.length > 0) {
@@ -805,7 +1007,7 @@ export default function TimelineEditor({
}
}
// Delete key or Ctrl+D / Cmd+D
- if (e.key === 'Delete' || e.key === 'Backspace' || ((e.key === 'd' || e.key === 'D') && (e.ctrlKey || e.metaKey))) {
+ if (e.key === 'Delete' || e.key === 'Backspace' || matchesShortcut(e, keyShortcuts.deleteSelected, isMac)) {
if (selectedKeyframeId) {
deleteSelectedKeyframe();
} else if (selectedZoomId) {
@@ -814,12 +1016,14 @@ export default function TimelineEditor({
deleteSelectedTrim();
} else if (selectedAnnotationId) {
deleteSelectedAnnotation();
+ } else if (selectedSpeedId) {
+ deleteSelectedSpeed();
}
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, annotationRegions, currentTime, onSelectAnnotation]);
+ }, [addKeyframe, handleAddZoom, handleAddTrim, handleAddAnnotation, handleAddSpeed, deleteSelectedKeyframe, deleteSelectedZoom, deleteSelectedTrim, deleteSelectedAnnotation, deleteSelectedSpeed, selectedKeyframeId, selectedZoomId, selectedTrimId, selectedAnnotationId, selectedSpeedId, annotationRegions, currentTime, onSelectAnnotation, keyShortcuts, isMac]);
const clampedRange = useMemo(() => {
if (totalMs === 0) {
@@ -872,26 +1076,85 @@ export default function TimelineEditor({
};
});
- return [...zooms, ...trims, ...annotations];
- }, [zoomRegions, trimRegions, annotationRegions]);
+ const speeds: TimelineRenderItem[] = speedRegions.map((region, index) => ({
+ id: region.id,
+ rowId: SPEED_ROW_ID,
+ span: { start: region.startMs, end: region.endMs },
+ label: `Speed ${index + 1}`,
+ speedValue: region.speed,
+ variant: 'speed',
+ }));
+
+ return [...zooms, ...trims, ...annotations, ...speeds];
+ }, [zoomRegions, trimRegions, annotationRegions, speedRegions]);
// Flat list of all non-annotation region spans for neighbour-clamping during drag/resize
const allRegionSpans = useMemo(() => {
const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
const trims = trimRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
- return [...zooms, ...trims];
- }, [zoomRegions, trimRegions]);
+ const speeds = speedRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs }));
+ return [...zooms, ...trims, ...speeds];
+ }, [zoomRegions, trimRegions, speedRegions]);
const handleItemSpanChange = useCallback((id: string, span: Span) => {
- // Check if it's a zoom or trim item
+ // Check if it's a zoom, trim, speed, or annotation item
if (zoomRegions.some(r => r.id === id)) {
onZoomSpanChange(id, span);
} else if (trimRegions.some(r => r.id === id)) {
onTrimSpanChange?.(id, span);
+ } else if (speedRegions.some(r => r.id === id)) {
+ onSpeedSpanChange?.(id, span);
} else if (annotationRegions.some(r => r.id === id)) {
onAnnotationSpanChange?.(id, span);
}
- }, [zoomRegions, trimRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onAnnotationSpanChange]);
+ }, [zoomRegions, trimRegions, speedRegions, annotationRegions, onZoomSpanChange, onTrimSpanChange, onSpeedSpanChange, onAnnotationSpanChange]);
+
+ const panTimelineRange = useCallback((deltaMs: number) => {
+ if (!Number.isFinite(deltaMs) || deltaMs === 0 || totalMs <= 0) {
+ return;
+ }
+
+ setRange((previous) => {
+ const visibleSpan = Math.max(1, previous.end - previous.start);
+ const maxStart = Math.max(0, totalMs - visibleSpan);
+ const nextStart = Math.max(0, Math.min(previous.start + deltaMs, maxStart));
+
+ return {
+ start: nextStart,
+ end: nextStart + visibleSpan,
+ };
+ });
+ }, [totalMs]);
+
+ const handleTimelineWheel = useCallback((event: WheelEvent) => {
+ if (event.ctrlKey || event.metaKey || totalMs <= 0) {
+ return;
+ }
+
+ const rawHorizontalDelta = Math.abs(event.deltaX) > 0
+ ? event.deltaX
+ : event.shiftKey && Math.abs(event.deltaY) > 0
+ ? event.deltaY
+ : 0;
+
+ if (rawHorizontalDelta === 0) {
+ return;
+ }
+
+ const containerWidth = timelineContainerRef.current?.clientWidth ?? 0;
+ const visibleRangeMs = clampedRange.end - clampedRange.start;
+
+ if (containerWidth <= 0 || visibleRangeMs <= 0) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode);
+ const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs;
+
+ panTimelineRange(deltaMs);
+ }, [clampedRange.end, clampedRange.start, panTimelineRange, totalMs]);
if (!videoDuration || videoDuration === 0) {
return (
@@ -908,18 +1171,27 @@ export default function TimelineEditor({
}
return (
-
+
+
+
+
+
+
+
@@ -959,7 +1240,7 @@ export default function TimelineEditor({
className="text-slate-300 hover:text-white hover:bg-white/10 cursor-pointer flex items-center justify-between gap-3"
>
{getAspectRatioLabel(ratio)}
- {aspectRatio === ratio && }
+ {aspectRatio === ratio && }
))}
@@ -970,19 +1251,24 @@ export default function TimelineEditor({
- {shortcuts.pan}
+ Side Scroll
Pan
- {shortcuts.zoom}
+ {scrollLabels.pan}
+ Pan
+
+
+ {scrollLabels.zoom}
Zoom
setSelectedKeyframeId(null)}
+ onWheel={handleTimelineWheel}
>
@@ -1006,17 +1291,17 @@ export default function TimelineEditor({
diff --git a/src/components/video-editor/timeline/TimelineWrapper.tsx b/src/components/video-editor/timeline/TimelineWrapper.tsx
index 35685b32..a3db714c 100644
--- a/src/components/video-editor/timeline/TimelineWrapper.tsx
+++ b/src/components/video-editor/timeline/TimelineWrapper.tsx
@@ -19,7 +19,7 @@ interface TimelineWrapperProps {
onRangeChange: Dispatch
>;
minItemDurationMs: number;
minVisibleRangeMs: number;
- gridSizeMs: number;
+ gridSizeMs?: number;
onItemSpanChange: (id: string, span: Span) => void;
allRegionSpans?: { id: string; start: number; end: number }[];
}
@@ -135,7 +135,10 @@ export default function TimelineWrapper({
const activeItemId = event.active.id as string;
let clampedSpan = clampSpanToBounds(updatedSpan);
- if (clampedSpan.end - clampedSpan.start < Math.min(minItemDurationMs, totalMs || minItemDurationMs)) {
+ const effectiveMinDuration = totalMs > 0
+ ? Math.min(minItemDurationMs, totalMs)
+ : minItemDurationMs;
+ if (clampedSpan.end - clampedSpan.start < effectiveMinDuration) {
return;
}
diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.ts
new file mode 100644
index 00000000..bd803ab2
--- /dev/null
+++ b/src/components/video-editor/timeline/zoomSuggestionUtils.ts
@@ -0,0 +1,268 @@
+import type { CursorTelemetryPoint, ZoomFocus } from "../types";
+
+export const MIN_DWELL_DURATION_MS = 450;
+export const MAX_DWELL_DURATION_MS = 2600;
+export const DWELL_MOVE_THRESHOLD = 0.02;
+
+export interface ZoomDwellCandidate {
+ centerTimeMs: number;
+ focus: ZoomFocus;
+ strength: number;
+}
+
+export interface CursorInteractionCandidate extends ZoomDwellCandidate {
+ kind: 'dwell' | 'click-like' | 'double-click-like' | 'text-focus-like' | 'dropdown-open' | 'text-selection' | 'text-field-click';
+}
+
+function normalizeTelemetrySample(sample: CursorTelemetryPoint, totalMs: number): CursorTelemetryPoint {
+ return {
+ timeMs: Math.max(0, Math.min(sample.timeMs, totalMs)),
+ cx: Math.max(0, Math.min(sample.cx, 1)),
+ cy: Math.max(0, Math.min(sample.cy, 1)),
+ interactionType: sample.interactionType,
+ cursorType: sample.cursorType,
+ };
+}
+
+function applyCursorTypeInRange(samples: CursorTelemetryPoint[], startMs: number, endMs: number, cursorType: NonNullable) {
+ for (const sample of samples) {
+ if (sample.timeMs < startMs || sample.timeMs > endMs) continue;
+ if (!sample.cursorType) {
+ sample.cursorType = cursorType;
+ }
+ }
+}
+
+export function normalizeCursorTelemetry(
+ telemetry: CursorTelemetryPoint[],
+ totalMs: number,
+): CursorTelemetryPoint[] {
+ const normalized = [...telemetry]
+ .filter((sample) => Number.isFinite(sample.timeMs) && Number.isFinite(sample.cx) && Number.isFinite(sample.cy))
+ .sort((a, b) => a.timeMs - b.timeMs)
+ .map((sample) => normalizeTelemetrySample(sample, totalMs));
+
+ const interactions = detectInteractionCandidates(normalized);
+ for (const candidate of interactions) {
+ if (candidate.kind === 'text-selection') {
+ applyCursorTypeInRange(normalized, candidate.centerTimeMs - 140, candidate.centerTimeMs + 1200, 'text');
+ continue;
+ }
+
+ if (candidate.kind === 'text-field-click' || candidate.kind === 'text-focus-like') {
+ applyCursorTypeInRange(normalized, candidate.centerTimeMs - 100, candidate.centerTimeMs + 900, 'text');
+ continue;
+ }
+ }
+
+ for (const sample of normalized) {
+ if (sample.interactionType !== 'click' && sample.interactionType !== 'double-click') {
+ continue;
+ }
+
+ const mouseUp = normalized.find((candidate) => candidate.timeMs > sample.timeMs && candidate.interactionType === 'mouseup');
+ if (!mouseUp) {
+ continue;
+ }
+
+ const dragDuration = mouseUp.timeMs - sample.timeMs;
+ const dragDistance = Math.hypot(mouseUp.cx - sample.cx, mouseUp.cy - sample.cy);
+ if (dragDuration >= 160 && dragDistance > 0.015) {
+ const isTextDrag = Math.abs(mouseUp.cx - sample.cx) > Math.abs(mouseUp.cy - sample.cy) * 1.8;
+ applyCursorTypeInRange(normalized, sample.timeMs, mouseUp.timeMs, isTextDrag ? 'text' : 'closed-hand');
+ }
+ }
+
+ return normalized;
+}
+
+export function detectZoomDwellCandidates(samples: CursorTelemetryPoint[]): ZoomDwellCandidate[] {
+ if (samples.length < 2) {
+ return [];
+ }
+
+ const dwellCandidates: ZoomDwellCandidate[] = [];
+ let runStart = 0;
+
+ const pushRunIfDwell = (startIndex: number, endIndexExclusive: number) => {
+ if (endIndexExclusive - startIndex < 2) {
+ return;
+ }
+
+ const start = samples[startIndex];
+ const end = samples[endIndexExclusive - 1];
+ const runDuration = end.timeMs - start.timeMs;
+ if (runDuration < MIN_DWELL_DURATION_MS || runDuration > MAX_DWELL_DURATION_MS) {
+ return;
+ }
+
+ const runSamples = samples.slice(startIndex, endIndexExclusive);
+ const avgCx = runSamples.reduce((sum, sample) => sum + sample.cx, 0) / runSamples.length;
+ const avgCy = runSamples.reduce((sum, sample) => sum + sample.cy, 0) / runSamples.length;
+
+ dwellCandidates.push({
+ centerTimeMs: Math.round((start.timeMs + end.timeMs) / 2),
+ focus: { cx: avgCx, cy: avgCy },
+ strength: runDuration,
+ });
+ };
+
+ for (let index = 1; index < samples.length; index += 1) {
+ const prev = samples[index - 1];
+ const curr = samples[index];
+ const distance = Math.hypot(curr.cx - prev.cx, curr.cy - prev.cy);
+
+ if (distance > DWELL_MOVE_THRESHOLD) {
+ pushRunIfDwell(runStart, index);
+ runStart = index;
+ }
+ }
+ pushRunIfDwell(runStart, samples.length);
+
+ return dwellCandidates;
+}
+
+export function detectInteractionCandidates(samples: CursorTelemetryPoint[]): CursorInteractionCandidate[] {
+ // --- Phase 1: Explicit interaction events (from uiohook telemetry) ---
+ const clickEvents = samples.filter(
+ (s) => s.interactionType && s.interactionType !== 'move' && s.interactionType !== 'mouseup',
+ );
+
+ const explicitInteractionCandidates: CursorInteractionCandidate[] = [];
+
+ for (const clickSample of clickEvents) {
+ // Classify what happened AFTER this click by analyzing cursor trajectory
+ const kind = classifyPostClickBehavior(samples, clickSample);
+
+ const baseStrength =
+ kind === 'double-click-like' ? 1500 :
+ kind === 'dropdown-open' ? 1200 :
+ kind === 'text-selection' ? 1300 :
+ kind === 'text-field-click' ? 1100 :
+ 900;
+
+ explicitInteractionCandidates.push({
+ centerTimeMs: Math.round(clickSample.timeMs),
+ focus: { cx: clickSample.cx, cy: clickSample.cy },
+ strength: baseStrength,
+ kind,
+ });
+ }
+
+ // --- Phase 2: Dwell-based heuristic candidates ---
+ const dwellCandidates = detectZoomDwellCandidates(samples)
+ .map((candidate) => {
+ if (candidate.strength >= 1100) {
+ return { ...candidate, kind: 'text-focus-like' };
+ }
+ if (candidate.strength <= 800) {
+ return { ...candidate, kind: 'click-like' };
+ }
+ return { ...candidate, kind: 'dwell' };
+ });
+
+ // --- Phase 3: Synthetic double-click detection from dwell pairs ---
+ const doubleClickCandidates: CursorInteractionCandidate[] = [];
+ const sortedByTime = [...dwellCandidates].sort((a, b) => a.centerTimeMs - b.centerTimeMs);
+
+ for (let index = 1; index < sortedByTime.length; index += 1) {
+ const prev = sortedByTime[index - 1];
+ const curr = sortedByTime[index];
+ const timeGap = curr.centerTimeMs - prev.centerTimeMs;
+ const spatialGap = Math.hypot(curr.focus.cx - prev.focus.cx, curr.focus.cy - prev.focus.cy);
+ const bothShort = prev.strength <= 900 && curr.strength <= 900;
+
+ if (bothShort && timeGap <= 450 && spatialGap <= 0.035) {
+ doubleClickCandidates.push({
+ centerTimeMs: Math.round((prev.centerTimeMs + curr.centerTimeMs) / 2),
+ focus: {
+ cx: (prev.focus.cx + curr.focus.cx) / 2,
+ cy: (prev.focus.cy + curr.focus.cy) / 2,
+ },
+ strength: prev.strength + curr.strength + 500,
+ kind: 'double-click-like',
+ });
+ }
+ }
+
+ return [...explicitInteractionCandidates, ...dwellCandidates, ...doubleClickCandidates];
+}
+
+/**
+ * Analyzes cursor movement after a click to classify the interaction pattern.
+ *
+ * - **dropdown-open**: click followed by slow downward cursor movement (browsing items)
+ * - **text-selection**: click followed by primarily horizontal drag movement
+ * - **text-field-click**: click followed by cursor staying mostly still (dwell)
+ * - **double-click-like**: explicit double-click interaction type
+ * - **click-like**: generic click with no recognizable post-click pattern
+ */
+function classifyPostClickBehavior(
+ samples: CursorTelemetryPoint[],
+ clickSample: CursorTelemetryPoint,
+): CursorInteractionCandidate['kind'] {
+ // Explicit double-click from uiohook
+ if (clickSample.interactionType === 'double-click') {
+ return 'double-click-like';
+ }
+
+ const clickTime = clickSample.timeMs;
+
+ // Check for mouseup shortly after (drag detection)
+ const mouseUpAfter = samples.find(
+ (s) => s.interactionType === 'mouseup' && s.timeMs > clickTime && s.timeMs - clickTime < 3000,
+ );
+
+ if (mouseUpAfter) {
+ const dragDx = Math.abs(mouseUpAfter.cx - clickSample.cx);
+ const dragDy = Math.abs(mouseUpAfter.cy - clickSample.cy);
+ const dragDuration = mouseUpAfter.timeMs - clickTime;
+
+ // Text selection: horizontal drag > 3% of screen, mostly horizontal, duration 200ms+
+ if (dragDuration >= 200 && dragDx > 0.03 && dragDx > dragDy * 1.8) {
+ return 'text-selection';
+ }
+ }
+
+ // Analyze trajectory in the 400ms-2000ms window after click
+ const moveSamples = samples.filter(
+ (s) => s.timeMs > clickTime + 100 && s.timeMs <= clickTime + 2000 &&
+ (s.interactionType === 'move' || !s.interactionType),
+ );
+
+ if (moveSamples.length < 3) {
+ // Very few move samples after click = cursor stayed still = text field click
+ return 'text-field-click';
+ }
+
+ // Compute displacement from click position
+ let maxDist = 0;
+ let totalAbsDy = 0;
+ let totalAbsDx = 0;
+ for (const s of moveSamples) {
+ const dist = Math.hypot(s.cx - clickSample.cx, s.cy - clickSample.cy);
+ maxDist = Math.max(maxDist, dist);
+ totalAbsDx += Math.abs(s.cx - clickSample.cx);
+ totalAbsDy += Math.abs(s.cy - clickSample.cy);
+ }
+
+ // Cursor barely moved after click: text field click (dwell)
+ if (maxDist < 0.02) {
+ return 'text-field-click';
+ }
+
+ // Primarily downward movement after click: dropdown open
+ const lastMoveSample = moveSamples[moveSamples.length - 1];
+ const netDy = lastMoveSample.cy - clickSample.cy;
+
+ if (netDy > 0.03 && totalAbsDy > totalAbsDx * 1.5) {
+ return 'dropdown-open';
+ }
+
+ // Primarily horizontal movement: text selection (fallback if no mouseup)
+ if (totalAbsDx > 0.03 && totalAbsDx > totalAbsDy * 1.8) {
+ return 'text-selection';
+ }
+
+ return 'click-like';
+}
diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts
index e138d751..bee17644 100644
--- a/src/components/video-editor/types.ts
+++ b/src/components/video-editor/types.ts
@@ -13,6 +13,27 @@ export interface ZoomRegion {
focus: ZoomFocus;
}
+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';
+}
+
+export interface CursorVisualSettings {
+ size: number;
+ smoothing: number;
+ motionBlur: number;
+ clickBounce: 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_ZOOM_MOTION_BLUR = 0.35;
+
export interface TrimRegion {
id: string;
startMs: number;
@@ -50,6 +71,14 @@ export interface AnnotationTextStyle {
textAlign: 'left' | 'center' | 'right';
}
+function getDefaultAnnotationFontFamily() {
+ 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';
+}
+
export interface AnnotationRegion {
id: string;
startMs: number;
@@ -79,7 +108,7 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
color: '#ffffff',
backgroundColor: 'transparent',
fontSize: 32,
- fontFamily: 'Inter',
+ fontFamily: getDefaultAnnotationFontFamily(),
fontWeight: 'bold',
fontStyle: 'normal',
textDecoration: 'none',
@@ -88,7 +117,7 @@ export const DEFAULT_ANNOTATION_STYLE: AnnotationTextStyle = {
export const DEFAULT_FIGURE_DATA: FigureData = {
arrowDirection: 'right',
- color: '#34B27B',
+ color: '#2563EB',
strokeWidth: 4,
};
@@ -108,6 +137,27 @@ export const DEFAULT_CROP_REGION: CropRegion = {
height: 1,
};
+export type PlaybackSpeed = 0.25 | 0.5 | 0.75 | 1.25 | 1.5 | 1.75 | 2;
+
+export interface SpeedRegion {
+ id: string;
+ startMs: number;
+ endMs: number;
+ speed: PlaybackSpeed;
+}
+
+export const SPEED_OPTIONS: Array<{ speed: PlaybackSpeed; label: string }> = [
+ { speed: 0.25, label: "0.25×" },
+ { speed: 0.5, label: "0.5×" },
+ { speed: 0.75, label: "0.75×" },
+ { speed: 1.25, label: "1.25×" },
+ { speed: 1.5, label: "1.5×" },
+ { speed: 1.75, label: "1.75×" },
+ { speed: 2, label: "2×" },
+];
+
+export const DEFAULT_PLAYBACK_SPEED: PlaybackSpeed = 1.5;
+
export const ZOOM_DEPTH_SCALES: Record = {
1: 1.25,
2: 1.5,
diff --git a/src/components/video-editor/videoPlayback/constants.ts b/src/components/video-editor/videoPlayback/constants.ts
index e0dbf6ba..324076d4 100644
--- a/src/components/video-editor/videoPlayback/constants.ts
+++ b/src/components/video-editor/videoPlayback/constants.ts
@@ -1,7 +1,9 @@
import type { ZoomFocus } from "../types";
export const DEFAULT_FOCUS: ZoomFocus = { cx: 0.5, cy: 0.5 };
-export const TRANSITION_WINDOW_MS = 320;
-export const SMOOTHING_FACTOR = 0.12;
+export const TRANSITION_WINDOW_MS = 1015.05;
+export const ZOOM_IN_TRANSITION_WINDOW_MS = TRANSITION_WINDOW_MS * 1.5;
export const MIN_DELTA = 0.0001;
export const VIEWPORT_SCALE = 0.8;
+export const ZOOM_TRANSLATION_DEADZONE_PX = 1.25;
+export const ZOOM_SCALE_DEADZONE = 0.002;
diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts
new file mode 100644
index 00000000..399263b1
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts
@@ -0,0 +1,717 @@
+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';
+
+type CursorAssetKey = NonNullable;
+
+type LoadedCursorAsset = {
+ texture: Texture;
+ image: HTMLImageElement;
+ aspectRatio: number;
+ anchorX: number;
+ anchorY: number;
+};
+
+export interface CursorViewportRect {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/**
+ * Configuration for cursor rendering.
+ */
+export interface CursorRenderConfig {
+ /** Base cursor height in pixels (at reference width of 1920px) */
+ dotRadius: number;
+ /** Cursor fill color (hex number for PixiJS) */
+ dotColor: number;
+ /** Cursor opacity (0–1) */
+ dotAlpha: number;
+ /** Unused, kept for interface compatibility */
+ trailLength: number;
+ /** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */
+ smoothingFactor: number;
+ /** Directional cursor motion blur amount. */
+ motionBlur: number;
+ /** Click bounce multiplier. */
+ clickBounce: number;
+}
+
+export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
+ dotRadius: 28,
+ dotColor: 0xffffff,
+ dotAlpha: 0.95,
+ trailLength: 0,
+ smoothingFactor: 0.18,
+ motionBlur: 0,
+ clickBounce: 1,
+};
+
+const REFERENCE_WIDTH = 1920;
+const MIN_CURSOR_VIEWPORT_SCALE = 0.55;
+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_SHADOW_COLOR = 0x000000;
+const CURSOR_SHADOW_ALPHA = 0.35;
+const CURSOR_SHADOW_OFFSET_X = 0;
+const CURSOR_SHADOW_OFFSET_Y = 2;
+const CURSOR_SHADOW_BLUR = 3;
+const CURSOR_SHADOW_PADDING = 12;
+
+let cursorAssetsPromise: Promise | null = null;
+let loadedCursorAssets: Partial> = {};
+const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
+ 'arrow',
+ 'text',
+ 'pointer',
+ 'crosshair',
+ 'open-hand',
+ 'closed-hand',
+ 'resize-ew',
+ 'resize-ns',
+ 'not-allowed',
+];
+
+function loadImage(dataUrl: string) {
+ return new Promise((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.src = dataUrl;
+ });
+}
+
+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 }) {
+ if (!systemAsset || systemAsset.width <= 0 || systemAsset.height <= 0) {
+ return fallbackAnchor;
+ }
+
+ return {
+ x: clamp(systemAsset.hotspotX / systemAsset.width, 0, 1),
+ y: clamp(systemAsset.hotspotY / systemAsset.height, 0, 1),
+ };
+}
+
+/**
+ * Loads an SVG at `sampleSize × sampleSize`, crops the trim region out of it,
+ * and returns a PNG data-URL of the cropped result. This is required because
+ * SVG files have their own natural pixel size (e.g. 32×32) which does not
+ * match the 1024-sample coordinate space used by the trim measurements.
+ */
+async function rasterizeAndCropSvg(
+ url: string,
+ sampleSize: number,
+ trimX: number,
+ trimY: number,
+ trimWidth: number,
+ trimHeight: number,
+): Promise<{ dataUrl: string; width: number; height: number }> {
+ const img = await loadImage(url);
+
+ // Draw at full sample size
+ const srcCanvas = document.createElement('canvas');
+ srcCanvas.width = sampleSize;
+ srcCanvas.height = sampleSize;
+ const srcCtx = srcCanvas.getContext('2d')!;
+ srcCtx.drawImage(img, 0, 0, sampleSize, sampleSize);
+
+ // Crop to trim bounds
+ const dstCanvas = document.createElement('canvas');
+ dstCanvas.width = trimWidth;
+ dstCanvas.height = trimHeight;
+ const dstCtx = dstCanvas.getContext('2d')!;
+ dstCtx.drawImage(
+ srcCanvas,
+ trimX,
+ trimY,
+ trimWidth,
+ trimHeight,
+ 0,
+ 0,
+ trimWidth,
+ trimHeight,
+ );
+
+ return {
+ dataUrl: dstCanvas.toDataURL('image/png'),
+ width: dstCanvas.width,
+ height: dstCanvas.height,
+ };
+}
+
+function getCursorAsset(key: CursorAssetKey): LoadedCursorAsset {
+ const asset = loadedCursorAssets[key];
+ if (!asset) {
+ throw new Error(`Missing cursor asset for ${key}`);
+ }
+
+ return asset;
+}
+
+function getAvailableCursorKeys(): CursorAssetKey[] {
+ const loadedKeys = Object.keys(loadedCursorAssets) as CursorAssetKey[];
+ return loadedKeys.length > 0 ? loadedKeys : ['arrow'];
+}
+
+export async function preloadCursorAssets() {
+ if (!cursorAssetsPromise) {
+ cursorAssetsPromise = (async () => {
+ let systemCursors: Record = {};
+
+ try {
+ const result = await window.electronAPI.getSystemCursorAssets();
+ if (result.success && result.cursors) {
+ systemCursors = result.cursors;
+ }
+ } catch (error) {
+ console.warn('[CursorRenderer] Failed to fetch system cursor assets:', error);
+ }
+
+ const entries = await Promise.all(
+ SUPPORTED_CURSOR_KEYS.map(async (key) => {
+ const systemAsset = systemCursors[key];
+ const uploadedAsset = uploadedCursorAssets[key];
+ const assetUrl = uploadedAsset?.url ?? systemAsset?.dataUrl;
+
+ if (!assetUrl) {
+ console.warn(`[CursorRenderer] No cursor image for: ${key}`);
+ return null;
+ }
+
+ try {
+ let finalUrl: string;
+ let width: number;
+ let height: number;
+ let normalizedAnchor: { x: number; y: number };
+
+ if (uploadedAsset) {
+ const { trim, fallbackAnchor } = uploadedAsset;
+ const rasterized = await rasterizeAndCropSvg(
+ assetUrl,
+ UPLOADED_CURSOR_SAMPLE_SIZE,
+ trim.x,
+ trim.y,
+ trim.width,
+ trim.height,
+ );
+ finalUrl = rasterized.dataUrl;
+ width = rasterized.width;
+ height = rasterized.height;
+ normalizedAnchor = {
+ x: clamp((fallbackAnchor.x * trim.width) / width, 0, 1),
+ y: clamp((fallbackAnchor.y * trim.height) / height, 0, 1),
+ };
+ } else {
+ finalUrl = assetUrl;
+ const img = await loadImage(finalUrl);
+ width = img.naturalWidth;
+ height = img.naturalHeight;
+ 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;
+ } catch (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>;
+
+ if (!loadedCursorAssets.arrow) {
+ throw new Error('Failed to initialize the fallback arrow cursor asset');
+ }
+ })();
+ }
+
+ return cursorAssetsPromise;
+}
+
+/**
+ * Interpolates cursor position from telemetry samples at a given time.
+ * Uses linear interpolation between the two nearest samples.
+ */
+export function interpolateCursorPosition(
+ samples: CursorTelemetryPoint[],
+ timeMs: number,
+): { cx: number; cy: number } | null {
+ if (!samples || samples.length === 0) return null;
+
+ if (timeMs <= samples[0].timeMs) {
+ return { cx: samples[0].cx, cy: samples[0].cy };
+ }
+
+ if (timeMs >= samples[samples.length - 1].timeMs) {
+ return { cx: samples[samples.length - 1].cx, cy: samples[samples.length - 1].cy };
+ }
+
+ let lo = 0;
+ let hi = samples.length - 1;
+ while (lo < hi - 1) {
+ const mid = (lo + hi) >> 1;
+ if (samples[mid].timeMs <= timeMs) {
+ lo = mid;
+ } else {
+ hi = mid;
+ }
+ }
+
+ const a = samples[lo];
+ const b = samples[hi];
+ const span = b.timeMs - a.timeMs;
+ if (span <= 0) return { cx: a.cx, cy: a.cy };
+
+ const t = (timeMs - a.timeMs) / span;
+ return {
+ cx: a.cx + (b.cx - a.cx) * t,
+ cy: a.cy + (b.cy - a.cy) * t,
+ };
+}
+
+function findLatestSample(samples: CursorTelemetryPoint[], timeMs: number) {
+ if (samples.length === 0) return null;
+
+ let lo = 0;
+ let hi = samples.length - 1;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ if (samples[mid].timeMs <= timeMs) {
+ lo = mid;
+ } else {
+ hi = mid - 1;
+ }
+ }
+
+ return samples[lo]?.timeMs <= timeMs ? samples[lo] : null;
+}
+
+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') {
+ return sample;
+ }
+ }
+
+ return null;
+}
+
+function findLatestStableCursorType(samples: CursorTelemetryPoint[], timeMs: number) {
+ // Binary search to find position at timeMs, then scan backwards
+ let lo = 0;
+ let hi = samples.length - 1;
+ while (lo < hi) {
+ const mid = Math.ceil((lo + hi) / 2);
+ if (samples[mid].timeMs <= timeMs) {
+ lo = mid;
+ } else {
+ hi = mid - 1;
+ }
+ }
+
+ // Scan backwards from the position to find a sample with cursorType
+ // Skip click events only (not mouseup) to avoid transient re-type during clicks
+ for (let index = lo; index >= 0; index -= 1) {
+ const sample = samples[index];
+ if (sample.timeMs > timeMs) {
+ continue;
+ }
+
+ if (!sample.cursorType) {
+ continue;
+ }
+
+ 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';
+}
+
+function getCursorViewportScale(viewport: CursorViewportRect) {
+ return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH);
+}
+
+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;
+
+ return {
+ cursorType: findLatestStableCursorType(samples, timeMs),
+ clickBounceProgress,
+ clickProgress: latestClick && isClickEvent && ageMs <= CLICK_RING_FADE_MS
+ ? 1 - ageMs / CLICK_RING_FADE_MS
+ : 0,
+ };
+}
+
+/**
+ * Manages a smoothed cursor state that chases the interpolated target.
+ */
+export class SmoothedCursorState {
+ public x = 0.5;
+ public y = 0.5;
+ public trail: Array<{ x: number; y: number }> = [];
+ private smoothingFactor: number;
+ private trailLength: number;
+ private initialized = false;
+ private lastTimeMs: number | null = null;
+ private xSpring = createSpringState(0.5);
+ private ySpring = createSpringState(0.5);
+
+ constructor(config: Pick) {
+ this.smoothingFactor = config.smoothingFactor;
+ this.trailLength = config.trailLength;
+ }
+
+ update(targetX: number, targetY: number, timeMs: number): void {
+ if (!this.initialized) {
+ this.x = targetX;
+ this.y = targetY;
+ this.initialized = true;
+ this.lastTimeMs = timeMs;
+ this.xSpring.value = targetX;
+ this.ySpring.value = targetY;
+ this.xSpring.velocity = 0;
+ this.ySpring.velocity = 0;
+ this.xSpring.initialized = true;
+ this.ySpring.initialized = true;
+ this.trail = [];
+ return;
+ }
+
+ if (this.smoothingFactor <= 0 || (this.lastTimeMs !== null && timeMs < this.lastTimeMs)) {
+ this.snapTo(targetX, targetY, timeMs);
+ return;
+ }
+
+ this.trail.unshift({ x: this.x, y: this.y });
+ if (this.trail.length > this.trailLength) {
+ this.trail.length = this.trailLength;
+ }
+
+ const deltaMs = this.lastTimeMs === null ? 1000 / 60 : Math.max(1, timeMs - this.lastTimeMs);
+ this.lastTimeMs = timeMs;
+
+ const springConfig = getCursorSpringConfig(this.smoothingFactor);
+ this.x = stepSpringValue(this.xSpring, targetX, deltaMs, springConfig);
+ this.y = stepSpringValue(this.ySpring, targetY, deltaMs, springConfig);
+ }
+
+ setSmoothingFactor(smoothingFactor: number): void {
+ this.smoothingFactor = smoothingFactor;
+ }
+
+ snapTo(targetX: number, targetY: number, timeMs: number): void {
+ this.x = targetX;
+ this.y = targetY;
+ this.initialized = true;
+ this.lastTimeMs = timeMs;
+ this.xSpring.value = targetX;
+ this.ySpring.value = targetY;
+ this.xSpring.velocity = 0;
+ this.ySpring.velocity = 0;
+ this.xSpring.initialized = true;
+ this.ySpring.initialized = true;
+ this.trail = [];
+ }
+
+ reset(): void {
+ this.initialized = false;
+ this.lastTimeMs = null;
+ this.trail = [];
+ resetSpringState(this.xSpring, this.x);
+ resetSpringState(this.ySpring, this.y);
+ }
+}
+
+function drawClickRing(graphics: Graphics, px: number, py: number, h: number, progress: number) {
+ void graphics;
+ void px;
+ void py;
+ void h;
+ void progress;
+}
+
+export class PixiCursorOverlay {
+ public readonly container: Container;
+ private clickRingGraphics: Graphics;
+ private cursorShadowSprites: Partial>;
+ private cursorShadowFilters: Partial>;
+ private cursorSprites: Partial>;
+ private cursorMotionBlurFilter: MotionBlurFilter;
+ private state: SmoothedCursorState;
+ private config: CursorRenderConfig;
+ private lastRenderedPoint: { px: number; py: number } | null = null;
+ private lastRenderedTimeMs: number | null = null;
+
+ constructor(config: Partial = {}) {
+ this.config = { ...DEFAULT_CURSOR_CONFIG, ...config };
+ this.state = new SmoothedCursorState(this.config);
+
+ this.container = new Container();
+ this.container.label = 'cursor-overlay';
+
+ this.clickRingGraphics = new Graphics();
+ this.cursorShadowSprites = {};
+ this.cursorShadowFilters = {};
+ this.cursorSprites = {};
+ for (const key of getAvailableCursorKeys()) {
+ const asset = getCursorAsset(key);
+ const shadowSprite = new Sprite(asset.texture);
+ shadowSprite.anchor.set(asset.anchorX, asset.anchorY);
+ shadowSprite.visible = false;
+ shadowSprite.tint = CURSOR_SHADOW_COLOR;
+ shadowSprite.alpha = CURSOR_SHADOW_ALPHA;
+ const shadowFilter = new BlurFilter();
+ shadowFilter.blur = CURSOR_SHADOW_BLUR;
+ shadowFilter.quality = 4;
+ shadowFilter.padding = CURSOR_SHADOW_PADDING;
+ shadowSprite.filters = [shadowFilter];
+ this.cursorShadowSprites[key] = shadowSprite;
+ this.cursorShadowFilters[key] = shadowFilter;
+
+ const sprite = new Sprite(asset.texture);
+ sprite.anchor.set(asset.anchorX, asset.anchorY);
+ sprite.visible = false;
+ this.cursorSprites[key] = sprite;
+ }
+
+ this.cursorMotionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
+ this.container.filters = null;
+
+ this.container.addChild(
+ this.clickRingGraphics,
+ ...Object.values(this.cursorShadowSprites),
+ ...Object.values(this.cursorSprites),
+ );
+ this.setMotionBlur(this.config.motionBlur);
+ }
+
+ setDotRadius(dotRadius: number) {
+ this.config.dotRadius = dotRadius;
+ }
+
+ setSmoothingFactor(smoothingFactor: number) {
+ this.config.smoothingFactor = smoothingFactor;
+ this.state.setSmoothingFactor(smoothingFactor);
+ }
+
+ setMotionBlur(motionBlur: number) {
+ this.config.motionBlur = Math.max(0, motionBlur);
+ 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;
+ this.cursorMotionBlurFilter.offset = 0;
+ }
+ }
+
+ setClickBounce(clickBounce: number) {
+ this.config.clickBounce = Math.max(0, clickBounce);
+ }
+
+ update(
+ samples: CursorTelemetryPoint[],
+ timeMs: number,
+ viewport: CursorViewportRect,
+ visible: boolean,
+ freeze = false,
+ ): void {
+ if (!visible || samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) {
+ this.container.visible = false;
+ this.lastRenderedPoint = null;
+ this.lastRenderedTimeMs = null;
+ this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
+ return;
+ }
+
+ const target = interpolateCursorPosition(samples, timeMs);
+ if (!target) {
+ this.container.visible = false;
+ 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;
+
+ if (freeze || hasTimeDiscontinuity) {
+ if (!sameFrameTime || !this.lastRenderedPoint) {
+ this.state.snapTo(target.cx, target.cy, timeMs);
+ }
+ } else {
+ this.state.update(target.cx, target.cy, timeMs);
+ }
+ this.container.visible = true;
+
+ 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 asset = getCursorAsset(spriteKey);
+ 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 scaledH = h;
+
+ this.clickRingGraphics.clear();
+ drawClickRing(this.clickRingGraphics, px, py, h, clickProgress);
+
+ 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]>) {
+ 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);
+ }
+
+ if (sprite) {
+ sprite.alpha = this.config.dotAlpha;
+ sprite.height = scaledH * bounceScale;
+ sprite.width = scaledH * bounceScale * asset.aspectRatio;
+ sprite.position.set(px, py);
+ }
+
+ this.applyCursorMotionBlur(px, py, timeMs, freeze);
+ 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) {
+ this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
+ this.cursorMotionBlurFilter.kernelSize = 5;
+ this.cursorMotionBlurFilter.offset = 0;
+ return;
+ }
+
+ 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 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.offset = magnitude > 0.5 ? -0.25 : 0;
+ }
+
+ reset(): void {
+ this.state.reset();
+ this.clickRingGraphics.clear();
+ for (const shadowSprite of Object.values(this.cursorShadowSprites)) {
+ shadowSprite.visible = false;
+ shadowSprite.scale.set(1);
+ }
+ for (const sprite of Object.values(this.cursorSprites)) {
+ sprite.visible = false;
+ sprite.scale.set(1);
+ }
+ this.container.visible = false;
+ this.lastRenderedPoint = null;
+ this.lastRenderedTimeMs = null;
+ this.cursorMotionBlurFilter.velocity = { x: 0, y: 0 };
+ this.cursorMotionBlurFilter.kernelSize = 5;
+ this.cursorMotionBlurFilter.offset = 0;
+ }
+
+ destroy(): void {
+ this.clickRingGraphics.destroy();
+ for (const shadowFilter of Object.values(this.cursorShadowFilters)) {
+ shadowFilter.destroy();
+ }
+ this.cursorMotionBlurFilter.destroy();
+ this.container.destroy({ children: true });
+ }
+}
+
+export function drawCursorOnCanvas(
+ ctx: CanvasRenderingContext2D,
+ samples: CursorTelemetryPoint[],
+ timeMs: number,
+ viewport: CursorViewportRect,
+ smoothedState: SmoothedCursorState,
+ config: CursorRenderConfig = DEFAULT_CURSOR_CONFIG,
+): void {
+ if (samples.length === 0 || viewport.width <= 0 || viewport.height <= 0) return;
+
+ const target = interpolateCursorPosition(samples, timeMs);
+ if (!target) return;
+
+ smoothedState.update(target.cx, target.cy, timeMs);
+
+ 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 asset = getCursorAsset(spriteKey);
+ 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;
+
+ const drawHeight = h * bounceScale;
+ const drawWidth = drawHeight * asset.aspectRatio;
+ 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.restore();
+}
diff --git a/src/components/video-editor/videoPlayback/focusUtils.ts b/src/components/video-editor/videoPlayback/focusUtils.ts
index e8e9f419..24fc857e 100644
--- a/src/components/video-editor/videoPlayback/focusUtils.ts
+++ b/src/components/video-editor/videoPlayback/focusUtils.ts
@@ -5,28 +5,99 @@ interface StageSize {
height: number;
}
+function clamp(value: number, min: number, max: number) {
+ return Math.max(min, Math.min(max, value));
+}
+
+function easeIntoBoundary(normalized: number) {
+ const t = clamp(normalized, 0, 1);
+ return -t * t * t + 2 * t * t;
+}
+
+function softClampToRange(value: number, min: number, max: number, softness: number) {
+ const clamped = clamp(value, min, max);
+
+ if (softness <= 0 || max <= min) {
+ return clamped;
+ }
+
+ if (clamped < min + softness) {
+ const normalized = (clamped - min) / softness;
+ return min + softness * easeIntoBoundary(normalized);
+ }
+
+ if (clamped > max - softness) {
+ const normalized = (max - clamped) / softness;
+ return max - softness * easeIntoBoundary(normalized);
+ }
+
+ return clamped;
+}
+
+function getFocusBounds(depth: ZoomDepth) {
+ const zoomScale = ZOOM_DEPTH_SCALES[depth];
+ return getFocusBoundsForScale(zoomScale);
+}
+
+function getFocusBoundsForScale(zoomScale: number) {
+ const marginX = 1 / (2 * zoomScale);
+ const marginY = 1 / (2 * zoomScale);
+
+ return {
+ minX: marginX,
+ maxX: 1 - marginX,
+ minY: marginY,
+ maxY: 1 - marginY,
+ };
+}
+
export function clampFocusToStage(
focus: ZoomFocus,
depth: ZoomDepth,
- stageSize: StageSize
+ _stageSize: StageSize
): ZoomFocus {
- if (!stageSize.width || !stageSize.height) {
- return clampFocusToDepth(focus, depth);
- }
-
- const zoomScale = ZOOM_DEPTH_SCALES[depth];
-
- const windowWidth = stageSize.width / zoomScale;
- const windowHeight = stageSize.height / zoomScale;
-
- const marginX = windowWidth / (2 * stageSize.width);
- const marginY = windowHeight / (2 * stageSize.height);
-
const baseFocus = clampFocusToDepth(focus, depth);
+ const bounds = getFocusBounds(depth);
return {
- cx: Math.max(marginX, Math.min(1 - marginX, baseFocus.cx)),
- cy: Math.max(marginY, Math.min(1 - marginY, baseFocus.cy)),
+ cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
+ cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
+ };
+}
+
+export function clampFocusToScale(
+ focus: ZoomFocus,
+ zoomScale: number,
+): ZoomFocus {
+ const baseFocus = {
+ cx: clamp(focus.cx, 0, 1),
+ cy: clamp(focus.cy, 0, 1),
+ };
+ const bounds = getFocusBoundsForScale(zoomScale);
+
+ return {
+ cx: clamp(baseFocus.cx, bounds.minX, bounds.maxX),
+ cy: clamp(baseFocus.cy, bounds.minY, bounds.maxY),
+ };
+}
+
+export function softenFocusToScale(
+ focus: ZoomFocus,
+ zoomScale: number
+): ZoomFocus {
+ const baseFocus = {
+ cx: clamp(focus.cx, 0, 1),
+ cy: clamp(focus.cy, 0, 1),
+ };
+ const bounds = getFocusBoundsForScale(zoomScale);
+ const horizontalRange = bounds.maxX - bounds.minX;
+ const verticalRange = bounds.maxY - bounds.minY;
+ const horizontalSoftness = Math.min(0.12, horizontalRange * 0.35);
+ const verticalSoftness = Math.min(0.12, verticalRange * 0.35);
+
+ return {
+ cx: softClampToRange(baseFocus.cx, bounds.minX, bounds.maxX, horizontalSoftness),
+ cy: softClampToRange(baseFocus.cy, bounds.minY, bounds.maxY, verticalSoftness),
};
}
diff --git a/src/components/video-editor/videoPlayback/mathUtils.ts b/src/components/video-editor/videoPlayback/mathUtils.ts
index c629d59f..3cc4fc7d 100644
--- a/src/components/video-editor/videoPlayback/mathUtils.ts
+++ b/src/components/video-editor/videoPlayback/mathUtils.ts
@@ -2,7 +2,91 @@ export function clamp01(value: number) {
return Math.max(0, Math.min(1, value));
}
+function sampleCubicBezier(a1: number, a2: number, t: number) {
+ const oneMinusT = 1 - t;
+ return 3 * a1 * oneMinusT * oneMinusT * t
+ + 3 * a2 * oneMinusT * t * t
+ + t * t * t;
+}
+
+function sampleCubicBezierDerivative(a1: number, a2: number, t: number) {
+ const oneMinusT = 1 - t;
+ return 3 * a1 * oneMinusT * oneMinusT
+ + 6 * (a2 - a1) * oneMinusT * t
+ + 3 * (1 - a2) * t * t;
+}
+
+export function cubicBezier(x1: number, y1: number, x2: number, y2: number, t: number) {
+ const targetX = clamp01(t);
+ let solvedT = targetX;
+
+ for (let i = 0; i < 8; i += 1) {
+ const currentX = sampleCubicBezier(x1, x2, solvedT) - targetX;
+ const currentDerivative = sampleCubicBezierDerivative(x1, x2, solvedT);
+
+ if (Math.abs(currentX) < 1e-6 || Math.abs(currentDerivative) < 1e-6) {
+ break;
+ }
+
+ solvedT -= currentX / currentDerivative;
+ }
+
+ let lower = 0;
+ let upper = 1;
+ solvedT = clamp01(solvedT);
+
+ for (let i = 0; i < 10; i += 1) {
+ const currentX = sampleCubicBezier(x1, x2, solvedT);
+ if (Math.abs(currentX - targetX) < 1e-6) {
+ break;
+ }
+
+ if (currentX < targetX) {
+ lower = solvedT;
+ } else {
+ upper = solvedT;
+ }
+
+ solvedT = (lower + upper) / 2;
+ }
+
+ return sampleCubicBezier(y1, y2, solvedT);
+}
+
+export function easeOutExpo(t: number) {
+ const clamped = clamp01(t);
+ if (clamped === 1) {
+ return 1;
+ }
+
+ return 1 - Math.pow(2, -7 * clamped);
+}
+
+export function easeOutScreenStudio(t: number) {
+ return cubicBezier(0.16, 1, 0.3, 1, t);
+}
+
export function smoothStep(t: number) {
const clamped = clamp01(t);
return clamped * clamped * (3 - 2 * clamped);
}
+
+/**
+ * Gentle ease-in-out cubic — slow start, smooth middle, gentle landing.
+ * Used for zoom-in transitions.
+ */
+export function easeInOutCubic(t: number) {
+ const x = clamp01(t);
+ return x < 0.5
+ ? 4 * x * x * x
+ : 1 - Math.pow(-2 * x + 2, 3) / 2;
+}
+
+/**
+ * Ease-out cubic — starts at speed, then decelerates to a gentle stop.
+ * Used for zoom-out transitions so strength eases smoothly to zero.
+ */
+export function easeOutCubic(t: number) {
+ const x = clamp01(t);
+ return 1 - Math.pow(1 - x, 3);
+}
diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts
new file mode 100644
index 00000000..d96a20d2
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts
@@ -0,0 +1,142 @@
+import { spring } from 'motion';
+
+export interface SpringState {
+ value: number;
+ velocity: number;
+ initialized: boolean;
+}
+
+export interface SpringConfig {
+ stiffness: number;
+ damping: number;
+ mass: number;
+ restDelta?: number;
+ restSpeed?: number;
+}
+
+const CURSOR_SMOOTHING_MIN = 0;
+const CURSOR_SMOOTHING_MAX = 2;
+const CURSOR_SMOOTHING_LEGACY_MAX = 0.5;
+
+export function createSpringState(initialValue = 0): SpringState {
+ return {
+ value: initialValue,
+ velocity: 0,
+ initialized: false,
+ };
+}
+
+export function resetSpringState(state: SpringState, initialValue?: number) {
+ if (typeof initialValue === 'number') {
+ state.value = initialValue;
+ }
+
+ state.velocity = 0;
+ state.initialized = false;
+}
+
+export function clampDeltaMs(deltaMs: number, fallbackMs = 1000 / 60) {
+ if (!Number.isFinite(deltaMs) || deltaMs <= 0) {
+ return fallbackMs;
+ }
+
+ return Math.min(80, Math.max(1, deltaMs));
+}
+
+export function stepSpringValue(
+ state: SpringState,
+ target: number,
+ deltaMs: number,
+ config: SpringConfig,
+) {
+ const safeDeltaMs = clampDeltaMs(deltaMs);
+
+ if (!state.initialized || !Number.isFinite(state.value)) {
+ state.value = target;
+ state.velocity = 0;
+ state.initialized = true;
+ return state.value;
+ }
+
+ const restDelta = config.restDelta ?? 0.0005;
+ const restSpeed = config.restSpeed ?? 0.02;
+
+ if (Math.abs(target - state.value) <= restDelta && Math.abs(state.velocity) <= restSpeed) {
+ state.value = target;
+ state.velocity = 0;
+ return state.value;
+ }
+
+ const previousValue = state.value;
+ const generator = spring({
+ keyframes: [state.value, target],
+ velocity: state.velocity,
+ stiffness: config.stiffness,
+ damping: config.damping,
+ mass: config.mass,
+ restDelta,
+ restSpeed,
+ });
+
+ const result = generator.next(safeDeltaMs);
+ state.value = result.done ? target : result.value;
+ state.velocity = ((state.value - previousValue) / safeDeltaMs) * 1000;
+
+ if (result.done) {
+ state.velocity = 0;
+ }
+
+ return state.value;
+}
+
+export function getCursorSpringConfig(smoothingFactor: number): SpringConfig {
+ const clamped = Math.min(CURSOR_SMOOTHING_MAX, Math.max(CURSOR_SMOOTHING_MIN, smoothingFactor));
+
+ if (clamped <= 0) {
+ return {
+ stiffness: 1000,
+ damping: 100,
+ mass: 1,
+ restDelta: 0.0001,
+ restSpeed: 0.001,
+ };
+ }
+
+ if (clamped <= CURSOR_SMOOTHING_LEGACY_MAX) {
+ const legacyNormalized = Math.min(
+ 1,
+ Math.max(0, (clamped - CURSOR_SMOOTHING_MIN) / (CURSOR_SMOOTHING_LEGACY_MAX - CURSOR_SMOOTHING_MIN)),
+ );
+
+ return {
+ stiffness: 760 - legacyNormalized * 420,
+ damping: 34 + legacyNormalized * 24,
+ mass: 0.55 + legacyNormalized * 0.45,
+ restDelta: 0.0002,
+ restSpeed: 0.01,
+ };
+ }
+
+ const extendedNormalized = Math.min(
+ 1,
+ Math.max(0, (clamped - CURSOR_SMOOTHING_LEGACY_MAX) / (CURSOR_SMOOTHING_MAX - CURSOR_SMOOTHING_LEGACY_MAX)),
+ );
+
+ return {
+ stiffness: 340 - extendedNormalized * 180,
+ damping: 58 + extendedNormalized * 22,
+ mass: 1 + extendedNormalized * 0.35,
+ restDelta: 0.0002,
+ restSpeed: 0.01,
+ };
+}
+
+export function getZoomSpringConfig(): SpringConfig {
+ return {
+ stiffness: 320,
+ damping: 40,
+ mass: 0.92,
+ restDelta: 0.0005,
+ restSpeed: 0.015,
+ };
+}
\ No newline at end of file
diff --git a/src/components/video-editor/videoPlayback/overlayUtils.ts b/src/components/video-editor/videoPlayback/overlayUtils.ts
index 9acdca83..200d8778 100644
--- a/src/components/video-editor/videoPlayback/overlayUtils.ts
+++ b/src/components/video-editor/videoPlayback/overlayUtils.ts
@@ -6,13 +6,12 @@ interface OverlayUpdateParams {
indicatorEl: HTMLDivElement;
region: ZoomRegion | null;
focusOverride?: ZoomFocus;
- videoSize: { width: number; height: number };
- baseScale: number;
+ baseMask: { x: number; y: number; width: number; height: number };
isPlaying: boolean;
}
export function updateOverlayIndicator(params: OverlayUpdateParams) {
- const { overlayEl, indicatorEl, region, focusOverride, videoSize, baseScale, isPlaying } = params;
+ const { overlayEl, indicatorEl, region, focusOverride, baseMask, isPlaying } = params;
if (!region) {
indicatorEl.style.display = 'none';
@@ -29,7 +28,7 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
return;
}
- if (!videoSize.width || !videoSize.height || baseScale <= 0) {
+ if (!baseMask.width || !baseMask.height) {
indicatorEl.style.display = 'none';
overlayEl.style.pointerEvents = isPlaying ? 'none' : 'auto';
return;
@@ -42,20 +41,19 @@ export function updateOverlayIndicator(params: OverlayUpdateParams) {
{ width: stageWidth, height: stageHeight }
);
- // Zoom window shows the stage area that will be visible after zooming (1/zoomScale of stage dimensions)
- const indicatorWidth = stageWidth / zoomScale;
- const indicatorHeight = stageHeight / zoomScale;
+ const indicatorWidth = baseMask.width / zoomScale;
+ const indicatorHeight = baseMask.height / zoomScale;
- const rawLeft = focus.cx * stageWidth - indicatorWidth / 2;
- const rawTop = focus.cy * stageHeight - indicatorHeight / 2;
+ const rawLeft = baseMask.x + focus.cx * baseMask.width - indicatorWidth / 2;
+ const rawTop = baseMask.y + focus.cy * baseMask.height - indicatorHeight / 2;
- const adjustedLeft = indicatorWidth >= stageWidth
- ? (stageWidth - indicatorWidth) / 2
- : Math.max(0, Math.min(stageWidth - indicatorWidth, rawLeft));
+ const adjustedLeft = indicatorWidth >= baseMask.width
+ ? baseMask.x + (baseMask.width - indicatorWidth) / 2
+ : Math.max(baseMask.x, Math.min(baseMask.x + baseMask.width - indicatorWidth, rawLeft));
- const adjustedTop = indicatorHeight >= stageHeight
- ? (stageHeight - indicatorHeight) / 2
- : Math.max(0, Math.min(stageHeight - indicatorHeight, rawTop));
+ const adjustedTop = indicatorHeight >= baseMask.height
+ ? baseMask.y + (baseMask.height - indicatorHeight) / 2
+ : Math.max(baseMask.y, Math.min(baseMask.y + baseMask.height - indicatorHeight, rawTop));
indicatorEl.style.display = 'block';
indicatorEl.style.width = `${indicatorWidth}px`;
diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts
new file mode 100644
index 00000000..fd56d8d3
--- /dev/null
+++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts
@@ -0,0 +1,70 @@
+import arrowUrl from '../../../assets/cursors/Cursor=Default.svg';
+import closedHandUrl from '../../../assets/cursors/Cursor=Hand-(Grabbing).svg';
+import crosshairUrl from '../../../assets/cursors/Cursor=Cross.svg';
+import openHandUrl from '../../../assets/cursors/Cursor=Hand-(Open).svg';
+import pointerUrl from '../../../assets/cursors/Cursor=Hand-(Pointing).svg';
+import resizeEwUrl from '../../../assets/cursors/Cursor=Resize-West-East.svg';
+import resizeNsUrl from '../../../assets/cursors/Cursor=Resize-North-South.svg';
+import textUrl from '../../../assets/cursors/Cursor=Text-Cursor.svg';
+import type { CursorTelemetryPoint } from '../types';
+
+type CursorAssetKey = NonNullable;
+
+export type UploadedCursorAsset = {
+ url: string;
+ trim: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ fallbackAnchor: {
+ x: number;
+ y: number;
+ };
+};
+
+export const UPLOADED_CURSOR_SAMPLE_SIZE = 1024;
+
+export const uploadedCursorAssets: Partial> = {
+ arrow: {
+ url: arrowUrl,
+ trim: { x: 480, y: 435, width: 333, height: 553 },
+ fallbackAnchor: { x: 0.18, y: 0.1 },
+ },
+ text: {
+ url: textUrl,
+ trim: { x: 404, y: 192, width: 247, height: 596 },
+ fallbackAnchor: { x: 0.5, y: 0.5 },
+ },
+ pointer: {
+ url: pointerUrl,
+ trim: { x: 352, y: 441, width: 466, height: 583 },
+ fallbackAnchor: { x: 0.37, y: 0.08 },
+ },
+ crosshair: {
+ url: crosshairUrl,
+ trim: { x: 288, y: 288, width: 480, height: 480 },
+ fallbackAnchor: { x: 0.5, y: 0.5 },
+ },
+ 'open-hand': {
+ url: openHandUrl,
+ trim: { x: 288, y: 188, width: 512, height: 580 },
+ fallbackAnchor: { x: 0.5, y: 0.28 },
+ },
+ 'closed-hand': {
+ url: closedHandUrl,
+ trim: { x: 344, y: 365, width: 432, height: 403 },
+ fallbackAnchor: { x: 0.5, y: 0.28 },
+ },
+ 'resize-ew': {
+ url: resizeEwUrl,
+ trim: { x: 187, y: 384, width: 669, height: 270 },
+ fallbackAnchor: { x: 0.5, y: 0.5 },
+ },
+ 'resize-ns': {
+ url: resizeNsUrl,
+ trim: { x: 376, y: 178, width: 271, height: 669 },
+ fallbackAnchor: { x: 0.5, y: 0.5 },
+ },
+};
\ No newline at end of file
diff --git a/src/components/video-editor/videoPlayback/videoEventHandlers.ts b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
index 8a55545d..c5d92eeb 100644
--- a/src/components/video-editor/videoPlayback/videoEventHandlers.ts
+++ b/src/components/video-editor/videoPlayback/videoEventHandlers.ts
@@ -1,5 +1,5 @@
import type React from 'react';
-import type { TrimRegion } from '../types';
+import type { TrimRegion, SpeedRegion } from '../types';
interface VideoEventHandlersParams {
video: HTMLVideoElement;
@@ -11,6 +11,7 @@ interface VideoEventHandlersParams {
onPlayStateChange: (playing: boolean) => void;
onTimeUpdate: (time: number) => void;
trimRegionsRef: React.MutableRefObject;
+ speedRegionsRef: React.MutableRefObject;
}
export function createVideoEventHandlers(params: VideoEventHandlersParams) {
@@ -24,6 +25,7 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
onPlayStateChange,
onTimeUpdate,
trimRegionsRef,
+ speedRegionsRef,
} = params;
const emitTime = (timeValue: number) => {
@@ -39,16 +41,23 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
) || null;
};
+ // Helper function to find the active speed region at the current time
+ const findActiveSpeedRegion = (currentTimeMs: number): SpeedRegion | null => {
+ return speedRegionsRef.current.find(
+ (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs
+ ) || null;
+ };
+
function updateTime() {
if (!video) return;
-
+
const currentTimeMs = video.currentTime * 1000;
const activeTrimRegion = findActiveTrimRegion(currentTimeMs);
-
+
// If we're in a trim region during playback, skip to the end of it
if (activeTrimRegion && !video.paused && !video.ended) {
const skipToTime = activeTrimRegion.endMs / 1000;
-
+
// If the skip would take us past the video duration, pause instead
if (skipToTime >= video.duration) {
video.pause();
@@ -57,9 +66,12 @@ export function createVideoEventHandlers(params: VideoEventHandlersParams) {
emitTime(skipToTime);
}
} else {
+ // Apply playback speed from active speed region
+ const activeSpeedRegion = findActiveSpeedRegion(currentTimeMs);
+ video.playbackRate = activeSpeedRegion ? activeSpeedRegion.speed : 1;
emitTime(video.currentTime);
}
-
+
if (!video.paused && !video.ended) {
timeUpdateAnimationRef.current = requestAnimationFrame(updateTime);
}
diff --git a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
index 26a3817c..787de1ea 100644
--- a/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
+++ b/src/components/video-editor/videoPlayback/zoomRegionUtils.ts
@@ -1,31 +1,219 @@
-import type { ZoomRegion } from "../types";
-import { smoothStep } from "./mathUtils";
-import { TRANSITION_WINDOW_MS } from "./constants";
+import type { ZoomFocus, ZoomRegion } from "../types";
+import { ZOOM_DEPTH_SCALES } from "../types";
+import { TRANSITION_WINDOW_MS, ZOOM_IN_TRANSITION_WINDOW_MS } from "./constants";
+import { clampFocusToScale } from "./focusUtils";
+import { clamp01, cubicBezier, easeOutScreenStudio } from "./mathUtils";
+
+const CHAINED_ZOOM_PAN_GAP_MS = 1500;
+const CONNECTED_ZOOM_PAN_DURATION_MS = 1000;
+const ZOOM_IN_OVERLAP_MS = 500;
+
+type DominantRegionOptions = {
+ connectZooms?: boolean;
+};
+
+type ConnectedRegionPair = {
+ currentRegion: ZoomRegion;
+ nextRegion: ZoomRegion;
+ transitionStart: number;
+ transitionEnd: number;
+};
+
+type ConnectedPanTransition = {
+ progress: number;
+ startFocus: ZoomFocus;
+ endFocus: ZoomFocus;
+ startScale: number;
+ endScale: number;
+};
+
+function lerp(start: number, end: number, amount: number) {
+ return start + (end - start) * amount;
+}
+
+function easeConnectedPan(value: number) {
+ return cubicBezier(0.1, 0.0, 0.2, 1.0, value);
+}
export function computeRegionStrength(region: ZoomRegion, timeMs: number) {
- const leadInStart = region.startMs - TRANSITION_WINDOW_MS;
+ const zoomInEnd = region.startMs + ZOOM_IN_OVERLAP_MS;
+ const leadInStart = zoomInEnd - ZOOM_IN_TRANSITION_WINDOW_MS;
const leadOutEnd = region.endMs + TRANSITION_WINDOW_MS;
if (timeMs < leadInStart || timeMs > leadOutEnd) {
return 0;
}
- const fadeIn = smoothStep((timeMs - leadInStart) / TRANSITION_WINDOW_MS);
- const fadeOut = smoothStep((leadOutEnd - timeMs) / TRANSITION_WINDOW_MS);
- return Math.min(fadeIn, fadeOut);
+ if (timeMs < zoomInEnd) {
+ const progress = (timeMs - leadInStart) / ZOOM_IN_TRANSITION_WINDOW_MS;
+ return easeOutScreenStudio(progress);
+ }
+
+ if (timeMs <= region.endMs) {
+ return 1;
+ }
+
+ const progress = clamp01((timeMs - region.endMs) / TRANSITION_WINDOW_MS);
+ return 1 - easeOutScreenStudio(progress);
}
-export function findDominantRegion(regions: ZoomRegion[], timeMs: number) {
- let bestRegion: ZoomRegion | null = null;
- let bestStrength = 0;
+function getLinearFocus(start: ZoomFocus, end: ZoomFocus, amount: number): ZoomFocus {
+ return {
+ cx: lerp(start.cx, end.cx, amount),
+ cy: lerp(start.cy, end.cy, amount),
+ };
+}
- for (const region of regions) {
- const strength = computeRegionStrength(region, timeMs);
- if (strength > bestStrength) {
- bestStrength = strength;
- bestRegion = region;
+function getResolvedFocus(region: ZoomRegion, zoomScale: number): ZoomFocus {
+ return clampFocusToScale(region.focus, zoomScale);
+}
+
+function getConnectedRegionPairs(regions: ZoomRegion[]) {
+ const sortedRegions = [...regions].sort((a, b) => a.startMs - b.startMs);
+ const pairs: ConnectedRegionPair[] = [];
+
+ for (let index = 0; index < sortedRegions.length - 1; index += 1) {
+ const currentRegion = sortedRegions[index];
+ const nextRegion = sortedRegions[index + 1];
+ const gapMs = nextRegion.startMs - currentRegion.endMs;
+
+ if (gapMs > CHAINED_ZOOM_PAN_GAP_MS) {
+ continue;
+ }
+
+ pairs.push({
+ currentRegion,
+ nextRegion,
+ transitionStart: currentRegion.endMs,
+ transitionEnd: currentRegion.endMs + CONNECTED_ZOOM_PAN_DURATION_MS,
+ });
+ }
+
+ return pairs;
+}
+
+function getActiveRegion(regions: ZoomRegion[], timeMs: number, connectedPairs: ConnectedRegionPair[]) {
+ const activeRegions = regions
+ .map((region) => {
+ const outgoingPair = connectedPairs.find((pair) => pair.currentRegion.id === region.id);
+ if (outgoingPair && timeMs > outgoingPair.currentRegion.endMs) {
+ return { region, strength: 0 };
+ }
+
+ const incomingPair = connectedPairs.find((pair) => pair.nextRegion.id === region.id);
+ if (incomingPair && timeMs < incomingPair.transitionEnd) {
+ return { region, strength: 0 };
+ }
+
+ return { region, strength: computeRegionStrength(region, timeMs) };
+ })
+ .filter((entry) => entry.strength > 0)
+ .sort((left, right) => {
+ if (right.strength !== left.strength) {
+ return right.strength - left.strength;
+ }
+
+ return right.region.startMs - left.region.startMs;
+ });
+
+ if (activeRegions.length === 0) {
+ return null;
+ }
+
+ const activeRegion = activeRegions[0].region;
+ const activeScale = ZOOM_DEPTH_SCALES[activeRegion.depth];
+
+ return {
+ region: {
+ ...activeRegion,
+ focus: getResolvedFocus(activeRegion, activeScale),
+ },
+ strength: activeRegions[0].strength,
+ blendedScale: null,
+ };
+}
+
+function getConnectedRegionHold(timeMs: number, connectedPairs: ConnectedRegionPair[]) {
+ for (const pair of connectedPairs) {
+ if (timeMs > pair.transitionEnd && timeMs < pair.nextRegion.startMs) {
+ const nextScale = ZOOM_DEPTH_SCALES[pair.nextRegion.depth];
+ return {
+ region: {
+ ...pair.nextRegion,
+ focus: getResolvedFocus(pair.nextRegion, nextScale),
+ },
+ strength: 1,
+ blendedScale: null,
+ };
}
}
- return { region: bestRegion, strength: bestStrength };
+ return null;
+}
+
+function getConnectedRegionTransition(
+ connectedPairs: ConnectedRegionPair[],
+ timeMs: number,
+) {
+ for (const pair of connectedPairs) {
+ const { currentRegion, nextRegion, transitionStart, transitionEnd } = pair;
+
+ if (timeMs < transitionStart || timeMs > transitionEnd) {
+ continue;
+ }
+
+ const transitionProgress = easeConnectedPan(
+ clamp01((timeMs - transitionStart) / Math.max(1, transitionEnd - transitionStart)),
+ );
+ const currentScale = ZOOM_DEPTH_SCALES[currentRegion.depth];
+ const nextScale = ZOOM_DEPTH_SCALES[nextRegion.depth];
+ const transitionScale = lerp(currentScale, nextScale, transitionProgress);
+ const currentFocus = getResolvedFocus(currentRegion, currentScale);
+ const nextFocus = getResolvedFocus(nextRegion, nextScale);
+ const transitionFocus = getLinearFocus(currentFocus, nextFocus, transitionProgress);
+
+ return {
+ region: {
+ ...nextRegion,
+ focus: transitionFocus,
+ },
+ strength: 1,
+ blendedScale: transitionScale,
+ transition: {
+ progress: transitionProgress,
+ startFocus: currentFocus,
+ endFocus: nextFocus,
+ startScale: currentScale,
+ endScale: nextScale,
+ },
+ };
+ }
+
+ return null;
+}
+
+export function findDominantRegion(regions: ZoomRegion[], timeMs: number, options: DominantRegionOptions = {}): {
+ region: ZoomRegion | null;
+ strength: number;
+ blendedScale: number | null;
+ transition: ConnectedPanTransition | null;
+} {
+ const connectedPairs = options.connectZooms ? getConnectedRegionPairs(regions) : [];
+
+ if (options.connectZooms) {
+ const connectedTransition = getConnectedRegionTransition(connectedPairs, timeMs);
+ if (connectedTransition) {
+ return connectedTransition;
+ }
+
+ const connectedHold = getConnectedRegionHold(timeMs, connectedPairs);
+ if (connectedHold) {
+ return { ...connectedHold, transition: null };
+ }
+ }
+
+ const activeRegion = getActiveRegion(regions, timeMs, connectedPairs);
+ return activeRegion
+ ? { ...activeRegion, transition: null }
+ : { region: null, strength: 0, blendedScale: null, transition: null };
}
diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts
index 1f6618fc..c3a239ff 100644
--- a/src/components/video-editor/videoPlayback/zoomTransform.ts
+++ b/src/components/video-editor/videoPlayback/zoomTransform.ts
@@ -1,61 +1,238 @@
import { Container, BlurFilter } from 'pixi.js';
+import { MotionBlurFilter } from 'pixi-filters/motion-blur';
+
+const PEAK_VELOCITY_PPS = 2000;
+const MAX_BLUR_PX = 8;
+const VELOCITY_THRESHOLD_PPS = 15;
+
+export interface MotionBlurState {
+ lastFrameTimeMs: number;
+ prevCamX: number;
+ prevCamY: number;
+ prevCamScale: number;
+ initialized: boolean;
+}
+
+export function createMotionBlurState(): MotionBlurState {
+ return {
+ lastFrameTimeMs: 0,
+ prevCamX: 0,
+ prevCamY: 0,
+ prevCamScale: 1,
+ initialized: false,
+ };
+}
interface TransformParams {
cameraContainer: Container;
blurFilter: BlurFilter | null;
+ motionBlurFilter?: MotionBlurFilter | null;
stageSize: { width: number; height: number };
baseMask: { x: number; y: number; width: number; height: number };
zoomScale: number;
+ zoomProgress?: number;
focusX: number;
focusY: number;
motionIntensity: number;
+ motionVector?: { x: number; y: number };
isPlaying: boolean;
- motionBlurEnabled?: boolean;
+ motionBlurAmount?: number;
+ transformOverride?: AppliedTransform;
+ motionBlurState?: MotionBlurState;
+ frameTimeMs?: number;
}
-export function applyZoomTransform({
- cameraContainer,
- blurFilter,
+interface AppliedTransform {
+ scale: number;
+ x: number;
+ y: number;
+}
+
+interface FocusFromTransformGeometry {
+ stageSize: { width: number; height: number };
+ baseMask: { x: number; y: number; width: number; height: number };
+ zoomScale: number;
+ x: number;
+ y: number;
+}
+
+interface ZoomTransformGeometry {
+ stageSize: { width: number; height: number };
+ baseMask: { x: number; y: number; width: number; height: number };
+ zoomScale: number;
+ zoomProgress?: number;
+ focusX: number;
+ focusY: number;
+}
+
+export function computeZoomTransform({
stageSize,
baseMask,
zoomScale,
+ zoomProgress = 1,
focusX,
focusY,
- motionIntensity,
- isPlaying,
- motionBlurEnabled = false,
-}: TransformParams) {
+}: ZoomTransformGeometry): AppliedTransform {
if (
stageSize.width <= 0 ||
stageSize.height <= 0 ||
baseMask.width <= 0 ||
baseMask.height <= 0
) {
- return;
+ return { scale: 1, x: 0, y: 0 };
}
- // The focus point in stage coordinates (where the user clicked/selected)
- const focusStagePxX = focusX * stageSize.width;
- const focusStagePxY = focusY * stageSize.height;
-
- // Stage center (where we want the focus to end up after zoom)
+ const progress = Math.min(1, Math.max(0, zoomProgress));
+ const focusStagePxX = baseMask.x + focusX * baseMask.width;
+ const focusStagePxY = baseMask.y + focusY * baseMask.height;
const stageCenterX = stageSize.width / 2;
const stageCenterY = stageSize.height / 2;
+ const scale = 1 + (zoomScale - 1) * progress;
+ const finalX = stageCenterX - focusStagePxX * zoomScale;
+ const finalY = stageCenterY - focusStagePxY * zoomScale;
- // Apply zoom scale to camera container
- cameraContainer.scale.set(zoomScale);
+ return {
+ scale,
+ x: finalX * progress,
+ y: finalY * progress,
+ };
+}
- // Calculate camera position to keep focus point centered
- // After scaling, the focus point moves to (focusX * zoomScale, focusY * zoomScale)
- // We want it at stage center, so offset = center - (focus * scale)
- const cameraX = stageCenterX - focusStagePxX * zoomScale;
- const cameraY = stageCenterY - focusStagePxY * zoomScale;
+export function computeFocusFromTransform({
+ stageSize,
+ baseMask,
+ zoomScale,
+ x,
+ y,
+}: FocusFromTransformGeometry) {
+ if (
+ stageSize.width <= 0 ||
+ stageSize.height <= 0 ||
+ baseMask.width <= 0 ||
+ baseMask.height <= 0 ||
+ zoomScale <= 0
+ ) {
+ return { cx: 0.5, cy: 0.5 };
+ }
+
+ const stageCenterX = stageSize.width / 2;
+ const stageCenterY = stageSize.height / 2;
+ const focusStagePxX = (stageCenterX - x) / zoomScale;
+ const focusStagePxY = (stageCenterY - y) / zoomScale;
+
+ return {
+ cx: (focusStagePxX - baseMask.x) / baseMask.width,
+ cy: (focusStagePxY - baseMask.y) / baseMask.height,
+ };
+}
+
+export function applyZoomTransform({
+ cameraContainer,
+ blurFilter,
+ motionBlurFilter,
+ stageSize,
+ baseMask,
+ zoomScale,
+ zoomProgress = 1,
+ focusX,
+ focusY,
+ motionIntensity: _motionIntensity,
+ motionVector: _motionVector,
+ isPlaying,
+ motionBlurAmount = 0,
+ transformOverride,
+ motionBlurState,
+ frameTimeMs,
+}: TransformParams): AppliedTransform {
+ if (
+ stageSize.width <= 0 ||
+ stageSize.height <= 0 ||
+ baseMask.width <= 0 ||
+ baseMask.height <= 0
+ ) {
+ return { scale: 1, x: 0, y: 0 };
+ }
+
+ const transform = transformOverride ?? computeZoomTransform({
+ stageSize,
+ baseMask,
+ zoomScale,
+ zoomProgress,
+ focusX,
+ focusY,
+ });
+
+ // Apply position & scale to camera container
+ cameraContainer.scale.set(transform.scale);
+ cameraContainer.position.set(transform.x, transform.y);
- cameraContainer.position.set(cameraX, cameraY);
+ if (motionBlurState && motionBlurFilter && motionBlurAmount > 0 && isPlaying) {
+ const now = frameTimeMs ?? performance.now();
- if (blurFilter) {
- const shouldBlur = motionBlurEnabled && isPlaying && motionIntensity > 0.0005;
- const motionBlur = shouldBlur ? Math.min(6, motionIntensity * 120) : 0;
- blurFilter.blur = motionBlur;
+ if (!motionBlurState.initialized) {
+ motionBlurState.prevCamX = transform.x;
+ motionBlurState.prevCamY = transform.y;
+ motionBlurState.prevCamScale = transform.scale;
+ motionBlurState.lastFrameTimeMs = now;
+ motionBlurState.initialized = true;
+ motionBlurFilter.velocity = { x: 0, y: 0 };
+ motionBlurFilter.kernelSize = 5;
+ motionBlurFilter.offset = 0;
+ if (blurFilter) blurFilter.blur = 0;
+ } else {
+ const dtMs = Math.min(80, Math.max(1, now - motionBlurState.lastFrameTimeMs));
+ const dtSeconds = dtMs / 1000;
+ motionBlurState.lastFrameTimeMs = now;
+
+ // Camera displacement this frame (stage-px)
+ const dx = transform.x - motionBlurState.prevCamX;
+ const dy = transform.y - motionBlurState.prevCamY;
+ const dScale = transform.scale - motionBlurState.prevCamScale;
+
+ motionBlurState.prevCamX = transform.x;
+ motionBlurState.prevCamY = transform.y;
+ motionBlurState.prevCamScale = transform.scale;
+
+ // Velocity in px/s (translation + scale-change contribution)
+ const velocityX = dx / dtSeconds;
+ const velocityY = dy / dtSeconds;
+ const scaleVelocity = Math.abs(dScale / dtSeconds) * Math.max(stageSize.width, stageSize.height) * 0.5;
+ const speed = Math.sqrt(velocityX * velocityX + velocityY * velocityY) + scaleVelocity;
+
+ const normalised = Math.min(1, speed / PEAK_VELOCITY_PPS);
+ const targetBlur = speed < VELOCITY_THRESHOLD_PPS
+ ? 0
+ : normalised * normalised * MAX_BLUR_PX * motionBlurAmount;
+
+ const dirMag = Math.sqrt(velocityX * velocityX + velocityY * velocityY) || 1;
+ const velocityScale = targetBlur * 1.2;
+ motionBlurFilter.velocity = targetBlur > 0
+ ? { x: (velocityX / dirMag) * velocityScale, y: (velocityY / dirMag) * velocityScale }
+ : { x: 0, y: 0 };
+ motionBlurFilter.kernelSize = targetBlur > 4 ? 11 : targetBlur > 1.5 ? 9 : 5;
+ motionBlurFilter.offset = targetBlur > 0.5 ? -0.2 : 0;
+
+ if (blurFilter) {
+ blurFilter.blur = 0;
+ }
+ }
+ } else {
+ if (motionBlurFilter) {
+ motionBlurFilter.velocity = { x: 0, y: 0 };
+ motionBlurFilter.kernelSize = 5;
+ motionBlurFilter.offset = 0;
+ }
+ if (blurFilter) {
+ blurFilter.blur = 0;
+ }
+ if (motionBlurState) {
+ motionBlurState.initialized = false;
+ }
}
+
+ return {
+ scale: transform.scale,
+ x: transform.x,
+ y: transform.y,
+ };
}
diff --git a/src/contexts/ShortcutsContext.tsx b/src/contexts/ShortcutsContext.tsx
new file mode 100644
index 00000000..bc6a57b3
--- /dev/null
+++ b/src/contexts/ShortcutsContext.tsx
@@ -0,0 +1,60 @@
+import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
+import { DEFAULT_SHORTCUTS, mergeWithDefaults, type ShortcutsConfig } from '@/lib/shortcuts';
+import { isMac as getIsMac } from '@/utils/platformUtils';
+
+interface ShortcutsContextValue {
+ shortcuts: ShortcutsConfig;
+ isMac: boolean;
+ setShortcuts: (config: ShortcutsConfig) => void;
+ persistShortcuts: (config?: ShortcutsConfig) => Promise;
+ isConfigOpen: boolean;
+ openConfig: () => void;
+ closeConfig: () => void;
+}
+
+const ShortcutsContext = createContext(null);
+
+export function useShortcuts(): ShortcutsContextValue {
+ const ctx = useContext(ShortcutsContext);
+ if (!ctx) throw new Error('useShortcuts must be used within ');
+ return ctx;
+}
+
+export function ShortcutsProvider({ children }: { children: ReactNode }) {
+ const [shortcuts, setShortcuts] = useState(DEFAULT_SHORTCUTS);
+ const [isMac, setIsMac] = useState(false);
+ const [isConfigOpen, setIsConfigOpen] = useState(false);
+
+ useEffect(() => {
+ getIsMac().then(setIsMac).catch(() => {});
+
+ window.electronAPI.getShortcuts?.()
+ .then((saved) => {
+ if (saved) {
+ setShortcuts(mergeWithDefaults(saved as Partial));
+ }
+ })
+ .catch(() => {});
+ }, []);
+
+ const persistShortcuts = useCallback(
+ async (config?: ShortcutsConfig) => {
+ await window.electronAPI.saveShortcuts?.(config ?? shortcuts);
+ },
+ [shortcuts],
+ );
+
+ const openConfig = useCallback(() => setIsConfigOpen(true), []);
+ const closeConfig = useCallback(() => setIsConfigOpen(false), []);
+
+ const value = useMemo(
+ () => ({ shortcuts, isMac, setShortcuts, persistShortcuts, isConfigOpen, openConfig, closeConfig }),
+ [shortcuts, isMac, persistShortcuts, isConfigOpen, openConfig, closeConfig],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/hooks/useAudioLevelMeter.ts b/src/hooks/useAudioLevelMeter.ts
new file mode 100644
index 00000000..01a4afc5
--- /dev/null
+++ b/src/hooks/useAudioLevelMeter.ts
@@ -0,0 +1,106 @@
+import { useEffect, useRef, useState } from 'react'
+
+export interface AudioLevelMeterOptions {
+ enabled: boolean
+ deviceId?: string
+ smoothingFactor?: number
+}
+
+export function useAudioLevelMeter(options: AudioLevelMeterOptions) {
+ const [level, setLevel] = useState(0)
+ const audioContextRef = useRef(null)
+ const analyserRef = useRef(null)
+ const streamRef = useRef(null)
+ const animationFrameRef = useRef(null)
+
+ useEffect(() => {
+ const cleanup = () => {
+ if (animationFrameRef.current) {
+ cancelAnimationFrame(animationFrameRef.current)
+ animationFrameRef.current = null
+ }
+ if (streamRef.current) {
+ streamRef.current.getTracks().forEach((track) => track.stop())
+ streamRef.current = null
+ }
+ if (audioContextRef.current) {
+ audioContextRef.current.close().catch(() => {})
+ audioContextRef.current = null
+ }
+ analyserRef.current = null
+ }
+
+ if (!options.enabled) {
+ cleanup()
+ setLevel(0)
+ return cleanup
+ }
+
+ let mounted = true
+
+ const startMonitoring = async () => {
+ try {
+ const constraints: MediaStreamConstraints = {
+ audio: options.deviceId ? { deviceId: { exact: options.deviceId } } : true,
+ video: false,
+ }
+
+ const stream = await navigator.mediaDevices.getUserMedia(constraints)
+ if (!mounted) {
+ stream.getTracks().forEach((track) => track.stop())
+ return
+ }
+
+ streamRef.current = stream
+
+ const audioContext = new AudioContext()
+ if (audioContext.state === 'suspended') {
+ await audioContext.resume()
+ }
+ audioContextRef.current = audioContext
+
+ const analyser = audioContext.createAnalyser()
+ analyser.fftSize = 256
+ analyser.smoothingTimeConstant = options.smoothingFactor ?? 0.8
+ analyserRef.current = analyser
+
+ const source = audioContext.createMediaStreamSource(stream)
+ source.connect(analyser)
+
+ const dataArray = new Uint8Array(analyser.frequencyBinCount)
+
+ const updateLevel = () => {
+ if (!mounted || !analyserRef.current) return
+
+ analyser.getByteFrequencyData(dataArray)
+
+ let sum = 0
+ for (let index = 0; index < dataArray.length; index++) {
+ sum += dataArray[index] * dataArray[index]
+ }
+
+ const rms = Math.sqrt(sum / dataArray.length)
+ const normalizedLevel = Math.min(100, (rms / 255) * 100 * 2)
+ setLevel(normalizedLevel)
+ animationFrameRef.current = requestAnimationFrame(updateLevel)
+ }
+
+ updateLevel()
+ } catch (error) {
+ console.error('Error starting audio level monitoring:', error)
+ if (mounted) {
+ setLevel(0)
+ }
+ }
+ }
+
+ void startMonitoring()
+
+ return () => {
+ mounted = false
+ cleanup()
+ }
+ }, [options.deviceId, options.enabled, options.smoothingFactor])
+
+ return { level }
+}
\ No newline at end of file
diff --git a/src/hooks/useMicrophoneDevices.ts b/src/hooks/useMicrophoneDevices.ts
new file mode 100644
index 00000000..ea816ab5
--- /dev/null
+++ b/src/hooks/useMicrophoneDevices.ts
@@ -0,0 +1,77 @@
+import { useEffect, useState } from 'react'
+
+export interface MicrophoneDevice {
+ deviceId: string
+ label: string
+ groupId: string
+}
+
+export function useMicrophoneDevices(enabled: boolean = true) {
+ const [devices, setDevices] = useState([])
+ const [selectedDeviceId, setSelectedDeviceId] = useState('default')
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ if (!enabled) {
+ return
+ }
+
+ let mounted = true
+
+ const loadDevices = async () => {
+ try {
+ setIsLoading(true)
+ setError(null)
+
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
+ const allDevices = await navigator.mediaDevices.enumerateDevices()
+ const audioInputs = allDevices
+ .filter((device) => device.kind === 'audioinput')
+ .map((device) => ({
+ deviceId: device.deviceId,
+ label: device.label || `Microphone ${device.deviceId.slice(0, 8)}`,
+ groupId: device.groupId,
+ }))
+
+ stream.getTracks().forEach((track) => track.stop())
+
+ if (mounted) {
+ setDevices(audioInputs)
+ if (selectedDeviceId === 'default' && audioInputs.length > 0) {
+ setSelectedDeviceId(audioInputs[0].deviceId)
+ }
+ setIsLoading(false)
+ }
+ } catch (error) {
+ if (mounted) {
+ const message = error instanceof Error ? error.message : 'Failed to enumerate audio devices'
+ setError(message)
+ setIsLoading(false)
+ console.error('Error loading microphone devices:', error)
+ }
+ }
+ }
+
+ void loadDevices()
+
+ const handleDeviceChange = () => {
+ void loadDevices()
+ }
+
+ navigator.mediaDevices.addEventListener('devicechange', handleDeviceChange)
+
+ return () => {
+ mounted = false
+ navigator.mediaDevices.removeEventListener('devicechange', handleDeviceChange)
+ }
+ }, [enabled, selectedDeviceId])
+
+ return {
+ devices,
+ selectedDeviceId,
+ setSelectedDeviceId,
+ isLoading,
+ error,
+ }
+}
\ No newline at end of file
diff --git a/src/hooks/useScreenRecorder.ts b/src/hooks/useScreenRecorder.ts
index cfb2183f..5e67c5e3 100644
--- a/src/hooks/useScreenRecorder.ts
+++ b/src/hooks/useScreenRecorder.ts
@@ -1,85 +1,243 @@
-import { useState, useRef, useEffect } from "react";
+import { useState, useRef, useEffect, useCallback } from "react";
import { fixWebmDuration } from "@fix-webm-duration/fix";
+const TARGET_FRAME_RATE = 60;
+const TARGET_WIDTH = 3840;
+const TARGET_HEIGHT = 2160;
+const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
+const QHD_WIDTH = 2560;
+const QHD_HEIGHT = 1440;
+const QHD_PIXELS = QHD_WIDTH * QHD_HEIGHT;
+const BITRATE_4K = 45_000_000;
+const BITRATE_QHD = 28_000_000;
+const BITRATE_BASE = 18_000_000;
+const HIGH_FRAME_RATE_THRESHOLD = 60;
+const HIGH_FRAME_RATE_BOOST = 1.7;
+const DEFAULT_WIDTH = 1920;
+const DEFAULT_HEIGHT = 1080;
+const CODEC_ALIGNMENT = 2;
+const RECORDER_TIMESLICE_MS = 1000;
+const BITS_PER_MEGABIT = 1_000_000;
+const MIN_FRAME_RATE = 30;
+const CHROME_MEDIA_SOURCE = "desktop";
+const RECORDING_FILE_PREFIX = "recording-";
+const VIDEO_FILE_EXTENSION = ".webm";
+const AUDIO_BITRATE_VOICE = 128_000;
+const AUDIO_BITRATE_SYSTEM = 192_000;
+const MIC_GAIN_BOOST = 1.4;
+
type UseScreenRecorderReturn = {
recording: boolean;
toggleRecording: () => void;
+ preparePermissions: (options?: { startup?: boolean }) => Promise;
+ isMacOS: boolean;
+ microphoneEnabled: boolean;
+ setMicrophoneEnabled: (enabled: boolean) => void;
+ microphoneDeviceId: string | undefined;
+ setMicrophoneDeviceId: (deviceId: string | undefined) => void;
+ systemAudioEnabled: boolean;
+ setSystemAudioEnabled: (enabled: boolean) => void;
};
export function useScreenRecorder(): UseScreenRecorderReturn {
const [recording, setRecording] = useState(false);
+ const [starting, setStarting] = useState(false);
+ const [isMacOS, setIsMacOS] = useState(false);
+ const [microphoneEnabled, setMicrophoneEnabled] = useState(false);
+ const [microphoneDeviceId, setMicrophoneDeviceId] = useState(undefined);
+ const [systemAudioEnabled, setSystemAudioEnabled] = useState(false);
const mediaRecorder = useRef(null);
const stream = useRef(null);
+ const screenStream = useRef(null);
+ const microphoneStream = useRef(null);
+ const mixingContext = useRef(null);
const chunks = useRef([]);
const startTime = useRef(0);
+ const nativeScreenRecording = useRef(false);
+ const startInFlight = useRef(false);
+ const hasPromptedForReselect = useRef(false);
+
+ const preparePermissions = useCallback(async (options: { startup?: boolean } = {}) => {
+ const platform = await window.electronAPI.getPlatform();
+ if (platform !== "darwin") {
+ return true;
+ }
+
+ const screenPermission = await window.electronAPI.getScreenRecordingPermissionStatus();
+ if (!screenPermission.success || screenPermission.status !== "granted") {
+ await window.electronAPI.openScreenRecordingPreferences();
+ alert(
+ options.startup
+ ? "Recordly needs Screen Recording permission before you start. System Settings has been opened. After enabling it, quit and reopen Recordly."
+ : "Screen Recording permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
+ );
+ return false;
+ }
+
+ const accessibilityPermission = await window.electronAPI.getAccessibilityPermissionStatus();
+ if (!accessibilityPermission.success) {
+ return false;
+ }
+
+ if (accessibilityPermission.trusted) {
+ return true;
+ }
+
+ const requestedAccessibility = await window.electronAPI.requestAccessibilityPermission();
+ if (requestedAccessibility.success && requestedAccessibility.trusted) {
+ return true;
+ }
+
+ await window.electronAPI.openAccessibilityPreferences();
+ alert(
+ options.startup
+ ? "Recordly also needs Accessibility permission for cursor tracking. System Settings has been opened. After enabling it, quit and reopen Recordly."
+ : "Accessibility permission is still missing. System Settings has been opened again. Enable it, then quit and reopen Recordly before recording.",
+ );
+
+ return false;
+ }, []);
- // Target visually lossless 4K @ 60fps; fall back gracefully when hardware cannot keep up
- const TARGET_FRAME_RATE = 60;
- const TARGET_WIDTH = 3840;
- const TARGET_HEIGHT = 2160;
- const FOUR_K_PIXELS = TARGET_WIDTH * TARGET_HEIGHT;
const selectMimeType = () => {
const preferred = [
"video/webm;codecs=av1",
"video/webm;codecs=h264",
"video/webm;codecs=vp9",
"video/webm;codecs=vp8",
- "video/webm"
+ "video/webm",
];
- return preferred.find(type => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
+ return preferred.find((type) => MediaRecorder.isTypeSupported(type)) ?? "video/webm";
};
const computeBitrate = (width: number, height: number) => {
const pixels = width * height;
- const highFrameRateBoost = TARGET_FRAME_RATE >= 60 ? 1.7 : 1;
+ const highFrameRateBoost =
+ TARGET_FRAME_RATE >= HIGH_FRAME_RATE_THRESHOLD ? HIGH_FRAME_RATE_BOOST : 1;
if (pixels >= FOUR_K_PIXELS) {
- return Math.round(45_000_000 * highFrameRateBoost);
+ return Math.round(BITRATE_4K * highFrameRateBoost);
}
- if (pixels >= 2560 * 1440) {
- return Math.round(28_000_000 * highFrameRateBoost);
+ if (pixels >= QHD_PIXELS) {
+ return Math.round(BITRATE_QHD * highFrameRateBoost);
}
- return Math.round(18_000_000 * highFrameRateBoost);
+ return Math.round(BITRATE_BASE * highFrameRateBoost);
};
+ const cleanupCapturedMedia = useCallback(() => {
+ if (stream.current) {
+ stream.current.getTracks().forEach((track) => track.stop());
+ stream.current = null;
+ }
+
+ if (screenStream.current) {
+ screenStream.current.getTracks().forEach((track) => track.stop());
+ screenStream.current = null;
+ }
+
+ if (microphoneStream.current) {
+ microphoneStream.current.getTracks().forEach((track) => track.stop());
+ microphoneStream.current = null;
+ }
+
+ if (mixingContext.current) {
+ mixingContext.current.close().catch(() => {});
+ mixingContext.current = null;
+ }
+ }, []);
+
const stopRecording = useRef(() => {
- if (mediaRecorder.current?.state === "recording") {
- if (stream.current) {
- stream.current.getTracks().forEach(track => track.stop());
- }
- mediaRecorder.current.stop();
+ if (nativeScreenRecording.current) {
+ nativeScreenRecording.current = false;
setRecording(false);
+ void (async () => {
+ const result = await window.electronAPI.stopNativeScreenRecording();
+ window.electronAPI?.setRecordingState(false);
+
+ if (!result.success || !result.path) {
+ console.error("Failed to stop native screen recording:", result.error ?? result.message);
+ return;
+ }
+
+ await window.electronAPI.setCurrentVideoPath(result.path);
+ await window.electronAPI.switchToEditor();
+ })();
+ return;
+ }
+
+ if (mediaRecorder.current?.state === "recording") {
+ cleanupCapturedMedia();
+ mediaRecorder.current.stop();
+ setRecording(false);
window.electronAPI?.setRecordingState(false);
}
});
+ useEffect(() => {
+ void (async () => {
+ const platform = await window.electronAPI.getPlatform();
+ setIsMacOS(platform === "darwin");
+ })();
+ }, []);
+
useEffect(() => {
let cleanup: (() => void) | undefined;
-
+
if (window.electronAPI?.onStopRecordingFromTray) {
cleanup = window.electronAPI.onStopRecordingFromTray(() => {
stopRecording.current();
});
}
+ const removeRecordingStateListener = window.electronAPI?.onRecordingStateChanged?.((state) => {
+ setRecording(state.recording);
+ });
+
+ const removeRecordingInterruptedListener = window.electronAPI?.onRecordingInterrupted?.((state) => {
+ setRecording(false);
+ nativeScreenRecording.current = false;
+ cleanupCapturedMedia();
+ void window.electronAPI.setRecordingState(false);
+
+ if (state.reason === "window-unavailable" && !hasPromptedForReselect.current) {
+ hasPromptedForReselect.current = true;
+ alert(state.message);
+ void window.electronAPI.openSourceSelector();
+ } else {
+ console.error(state.message);
+ }
+ });
+
return () => {
- if (cleanup) cleanup();
-
+ cleanup?.();
+ removeRecordingStateListener?.();
+ removeRecordingInterruptedListener?.();
+
+ if (nativeScreenRecording.current) {
+ nativeScreenRecording.current = false;
+ void window.electronAPI.stopNativeScreenRecording();
+ }
+
if (mediaRecorder.current?.state === "recording") {
mediaRecorder.current.stop();
}
- if (stream.current) {
- stream.current.getTracks().forEach(track => track.stop());
- stream.current = null;
- }
+
+ cleanupCapturedMedia();
};
- }, []);
+ }, [cleanupCapturedMedia]);
const startRecording = async () => {
+ if (startInFlight.current) {
+ return;
+ }
+
+ hasPromptedForReselect.current = false;
+ startInFlight.current = true;
+ setStarting(true);
+
try {
const selectedSource = await window.electronAPI.getSelectedSource();
if (!selectedSource) {
@@ -87,75 +245,231 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
return;
}
- const mediaStream = await (navigator.mediaDevices as any).getUserMedia({
- audio: false,
- video: {
+ const permissionsReady = await preparePermissions();
+ if (!permissionsReady) {
+ return;
+ }
+
+ const platform = await window.electronAPI.getPlatform();
+ const useNativeMacScreenCapture =
+ platform === "darwin" &&
+ (selectedSource.id?.startsWith("screen:") || selectedSource.id?.startsWith("window:")) &&
+ typeof window.electronAPI.startNativeScreenRecording === "function";
+
+ if (useNativeMacScreenCapture) {
+ const nativeResult = await window.electronAPI.startNativeScreenRecording(selectedSource, {
+ capturesSystemAudio: systemAudioEnabled,
+ capturesMicrophone: microphoneEnabled,
+ microphoneDeviceId,
+ });
+ if (!nativeResult.success) {
+ throw new Error(
+ nativeResult.error ?? nativeResult.message ?? "Failed to start native screen recording",
+ );
+ }
+
+ nativeScreenRecording.current = true;
+ startTime.current = Date.now();
+ setRecording(true);
+ window.electronAPI?.setRecordingState(true);
+ return;
+ }
+
+ const wantsAudioCapture = microphoneEnabled || systemAudioEnabled;
+
+ try {
+ await window.electronAPI.hideOsCursor?.();
+ } catch {
+ console.warn("Could not hide OS cursor before recording.");
+ }
+
+ let videoTrack: MediaStreamTrack | undefined;
+ let systemAudioIncluded = false;
+
+ if (wantsAudioCapture) {
+ const videoConstraints = {
mandatory: {
- chromeMediaSource: "desktop",
+ chromeMediaSource: CHROME_MEDIA_SOURCE,
chromeMediaSourceId: selectedSource.id,
maxWidth: TARGET_WIDTH,
maxHeight: TARGET_HEIGHT,
maxFrameRate: TARGET_FRAME_RATE,
- minFrameRate: 30,
+ minFrameRate: MIN_FRAME_RATE,
},
- },
- });
- stream.current = mediaStream;
- if (!stream.current) {
+ };
+
+ let screenMediaStream: MediaStream;
+
+ if (systemAudioEnabled) {
+ try {
+ screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
+ audio: {
+ mandatory: {
+ chromeMediaSource: CHROME_MEDIA_SOURCE,
+ chromeMediaSourceId: selectedSource.id,
+ },
+ },
+ video: videoConstraints,
+ });
+ } catch (audioError) {
+ console.warn("System audio capture failed, falling back to video-only:", audioError);
+ alert("System audio is not available for this source. Recording will continue without system audio.");
+ screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
+ audio: false,
+ video: videoConstraints,
+ });
+ }
+ } else {
+ screenMediaStream = await (navigator.mediaDevices as any).getUserMedia({
+ audio: false,
+ video: videoConstraints,
+ });
+ }
+
+ screenStream.current = screenMediaStream;
+ stream.current = new MediaStream();
+
+ videoTrack = screenMediaStream.getVideoTracks()[0];
+ if (!videoTrack) {
+ throw new Error("Video track is not available.");
+ }
+
+ stream.current.addTrack(videoTrack);
+
+ if (microphoneEnabled) {
+ try {
+ microphoneStream.current = await navigator.mediaDevices.getUserMedia({
+ audio: microphoneDeviceId
+ ? {
+ deviceId: { exact: microphoneDeviceId },
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ }
+ : {
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ video: false,
+ });
+ } catch (audioError) {
+ console.warn("Failed to get microphone access:", audioError);
+ alert("Microphone access was denied. Recording will continue without microphone audio.");
+ setMicrophoneEnabled(false);
+ }
+ }
+
+ const systemAudioTrack = screenMediaStream.getAudioTracks()[0];
+ const micAudioTrack = microphoneStream.current?.getAudioTracks()[0];
+
+ if (systemAudioTrack && micAudioTrack) {
+ const context = new AudioContext();
+ mixingContext.current = context;
+ const systemSource = context.createMediaStreamSource(new MediaStream([systemAudioTrack]));
+ const micSource = context.createMediaStreamSource(new MediaStream([micAudioTrack]));
+ const micGain = context.createGain();
+ micGain.gain.value = MIC_GAIN_BOOST;
+ const destination = context.createMediaStreamDestination();
+
+ systemSource.connect(destination);
+ micSource.connect(micGain).connect(destination);
+
+ const mixedTrack = destination.stream.getAudioTracks()[0];
+ if (mixedTrack) {
+ stream.current.addTrack(mixedTrack);
+ systemAudioIncluded = true;
+ }
+ } else if (systemAudioTrack) {
+ stream.current.addTrack(systemAudioTrack);
+ systemAudioIncluded = true;
+ } else if (micAudioTrack) {
+ stream.current.addTrack(micAudioTrack);
+ }
+ } else {
+ const mediaStream = await navigator.mediaDevices.getDisplayMedia({
+ audio: false,
+ video: {
+ displaySurface: selectedSource.id?.startsWith("window:") ? "window" : "monitor",
+ width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
+ height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
+ frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
+ cursor: "never",
+ },
+ selfBrowserSurface: "exclude",
+ surfaceSwitching: "exclude",
+ } as any);
+
+ stream.current = mediaStream;
+ videoTrack = mediaStream.getVideoTracks()[0];
+ }
+
+ if (!stream.current || !videoTrack) {
throw new Error("Media stream is not available.");
}
- const videoTrack = stream.current.getVideoTracks()[0];
+
try {
await videoTrack.applyConstraints({
frameRate: { ideal: TARGET_FRAME_RATE, max: TARGET_FRAME_RATE },
width: { ideal: TARGET_WIDTH, max: TARGET_WIDTH },
height: { ideal: TARGET_HEIGHT, max: TARGET_HEIGHT },
- });
+ } as MediaTrackConstraints);
} catch (error) {
- console.warn("Unable to lock 4K/60fps constraints, using best available track settings.", error);
+ console.warn(
+ "Unable to lock 4K/60fps constraints, using best available track settings.",
+ error,
+ );
}
- let { width = 1920, height = 1080, frameRate = TARGET_FRAME_RATE } = videoTrack.getSettings();
-
- // Ensure dimensions are divisible by 2 for VP9/AV1 codec compatibility
- width = Math.floor(width / 2) * 2;
- height = Math.floor(height / 2) * 2;
-
+ let {
+ width = DEFAULT_WIDTH,
+ height = DEFAULT_HEIGHT,
+ frameRate = TARGET_FRAME_RATE,
+ } = videoTrack.getSettings();
+
+ width = Math.floor(width / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
+ height = Math.floor(height / CODEC_ALIGNMENT) * CODEC_ALIGNMENT;
+
const videoBitsPerSecond = computeBitrate(width, height);
const mimeType = selectMimeType();
console.log(
`Recording at ${width}x${height} @ ${frameRate ?? TARGET_FRAME_RATE}fps using ${mimeType} / ${Math.round(
- videoBitsPerSecond / 1_000_000
- )} Mbps`
+ videoBitsPerSecond / BITS_PER_MEGABIT,
+ )} Mbps`,
);
-
+
chunks.current = [];
+ const hasAudio = stream.current.getAudioTracks().length > 0;
const recorder = new MediaRecorder(stream.current, {
mimeType,
videoBitsPerSecond,
+ ...(hasAudio
+ ? { audioBitsPerSecond: systemAudioIncluded ? AUDIO_BITRATE_SYSTEM : AUDIO_BITRATE_VOICE }
+ : {}),
});
+
mediaRecorder.current = recorder;
- recorder.ondataavailable = e => {
- if (e.data && e.data.size > 0) chunks.current.push(e.data);
+ recorder.ondataavailable = (event) => {
+ if (event.data && event.data.size > 0) chunks.current.push(event.data);
};
recorder.onstop = async () => {
- stream.current = null;
+ cleanupCapturedMedia();
if (chunks.current.length === 0) return;
+
const duration = Date.now() - startTime.current;
const recordedChunks = chunks.current;
const buggyBlob = new Blob(recordedChunks, { type: mimeType });
- // Clear chunks early to free memory immediately after blob creation
chunks.current = [];
const timestamp = Date.now();
- const videoFileName = `recording-${timestamp}.webm`;
+ const videoFileName = `${RECORDING_FILE_PREFIX}${timestamp}${VIDEO_FILE_EXTENSION}`;
try {
const videoBlob = await fixWebmDuration(buggyBlob, duration);
const arrayBuffer = await videoBlob.arrayBuffer();
const videoResult = await window.electronAPI.storeRecordedVideo(arrayBuffer, videoFileName);
if (!videoResult.success) {
- console.error('Failed to store video:', videoResult.message);
+ console.error("Failed to store video:", videoResult.message);
return;
}
@@ -165,27 +479,45 @@ export function useScreenRecorder(): UseScreenRecorderReturn {
await window.electronAPI.switchToEditor();
} catch (error) {
- console.error('Error saving recording:', error);
+ console.error("Error saving recording:", error);
}
};
- recorder.onerror = () => setRecording(false);
- recorder.start(1000);
+ recorder.onerror = () => {
+ setRecording(false);
+ };
+ recorder.start(RECORDER_TIMESLICE_MS);
startTime.current = Date.now();
setRecording(true);
window.electronAPI?.setRecordingState(true);
} catch (error) {
- console.error('Failed to start recording:', error);
+ console.error("Failed to start recording:", error);
+ alert(error instanceof Error ? `Failed to start recording: ${error.message}` : "Failed to start recording");
setRecording(false);
- if (stream.current) {
- stream.current.getTracks().forEach(track => track.stop());
- stream.current = null;
- }
+ cleanupCapturedMedia();
+ } finally {
+ startInFlight.current = false;
+ setStarting(false);
}
};
const toggleRecording = () => {
+ if (starting) {
+ return;
+ }
+
recording ? stopRecording.current() : startRecording();
};
- return { recording, toggleRecording };
+ return {
+ recording,
+ toggleRecording,
+ preparePermissions,
+ isMacOS,
+ microphoneEnabled,
+ setMicrophoneEnabled,
+ microphoneDeviceId,
+ setMicrophoneDeviceId,
+ systemAudioEnabled,
+ setSystemAudioEnabled,
+ };
}
diff --git a/src/index.css b/src/index.css
index 6ab421f2..90eb139c 100644
--- a/src/index.css
+++ b/src/index.css
@@ -4,25 +4,27 @@
@layer base {
:root {
+ --brand-accent: #2563eb;
+ --brand-accent-rgb: 37, 99, 235;
--background: 0 0% 100%;
--foreground: 20 14.3% 4.1%;
--card: 0 0% 100%;
--card-foreground: 20 14.3% 4.1%;
--popover: 0 0% 100%;
--popover-foreground: 20 14.3% 4.1%;
- --primary: 24 9.8% 10%;
- --primary-foreground: 60 9.1% 97.8%;
+ --primary: 221 83% 53%;
+ --primary-foreground: 210 40% 98%;
--secondary: 60 4.8% 95.9%;
--secondary-foreground: 24 9.8% 10%;
--muted: 60 4.8% 95.9%;
--muted-foreground: 25 5.3% 44.7%;
- --accent: 60 4.8% 95.9%;
- --accent-foreground: 24 9.8% 10%;
+ --accent: 214 100% 97%;
+ --accent-foreground: 221 83% 40%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 20 5.9% 90%;
--input: 20 5.9% 90%;
- --ring: 20 14.3% 4.1%;
+ --ring: 221 83% 53%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
@@ -37,19 +39,19 @@
--card-foreground: 60 9.1% 97.8%;
--popover: 20 14.3% 4.1%;
--popover-foreground: 60 9.1% 97.8%;
- --primary: 60 9.1% 97.8%;
- --primary-foreground: 24 9.8% 10%;
+ --primary: 221 83% 53%;
+ --primary-foreground: 210 40% 98%;
--secondary: 12 6.5% 15.1%;
--secondary-foreground: 60 9.1% 97.8%;
--muted: 12 6.5% 15.1%;
--muted-foreground: 24 5.4% 63.9%;
- --accent: 12 6.5% 15.1%;
- --accent-foreground: 60 9.1% 97.8%;
+ --accent: 217 33% 17%;
+ --accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 60 9.1% 97.8%;
--border: 12 6.5% 15.1%;
--input: 12 6.5% 15.1%;
- --ring: 24 5.7% 82.9%;
+ --ring: 221 83% 53%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
@@ -68,6 +70,22 @@
-webkit-user-select: none;
user-select: none;
}
+
+ button,
+ input,
+ textarea,
+ select {
+ font: inherit;
+ }
+
+ [data-platform="macos"] body {
+ font-family: "SF Pro Display", "SF Pro Text", -apple-system, BlinkMacSystemFont, sans-serif;
+ }
+
+ [data-platform="macos"] body .font-mono,
+ [data-platform="macos"] body kbd {
+ font-family: "SF Pro Text", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif;
+ }
}
/* Smooth timeline cursor animations */
@@ -110,7 +128,7 @@
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255,255,255,0.5);
- border-color: #34B27B;
+ border-color: var(--brand-accent);
}
input[type="range"]::-webkit-slider-thumb:active {
@@ -132,7 +150,7 @@
background: #fff;
transform: scale(1.15);
box-shadow: 0 3px 10px rgba(255,255,255,0.5);
- border-color: #34B27B;
+ border-color: var(--brand-accent);
}
input[type="range"]::-moz-range-thumb:active {
diff --git a/src/lib/assetPath.ts b/src/lib/assetPath.ts
index ccc58bf0..a8a528eb 100644
--- a/src/lib/assetPath.ts
+++ b/src/lib/assetPath.ts
@@ -22,4 +22,76 @@ export async function getAssetPath(relativePath: string): Promise {
return `/${relativePath.replace(/^\//, '')}`
}
+const BASE64_CHUNK_SIZE = 0x8000
+const localFileDataUrlCache = new Map()
+
+function toLocalFilePath(resourceUrl: string) {
+ if (!resourceUrl.startsWith('file://')) {
+ return null
+ }
+
+ const decodedPath = decodeURIComponent(resourceUrl.replace(/^file:\/\//, ''))
+ if (/^\/[A-Za-z]:/.test(decodedPath)) {
+ return decodedPath.slice(1)
+ }
+
+ return decodedPath
+}
+
+function getMimeTypeForAsset(resourceUrl: string) {
+ const normalized = resourceUrl.split('?')[0].toLowerCase()
+
+ if (normalized.endsWith('.png')) return 'image/png'
+ if (normalized.endsWith('.webp')) return 'image/webp'
+ if (normalized.endsWith('.gif')) return 'image/gif'
+ if (normalized.endsWith('.svg')) return 'image/svg+xml'
+ if (normalized.endsWith('.avif')) return 'image/avif'
+ return 'image/jpeg'
+}
+
+function toBase64(bytes: Uint8Array) {
+ let binary = ''
+
+ for (let index = 0; index < bytes.length; index += BASE64_CHUNK_SIZE) {
+ const chunk = bytes.subarray(index, index + BASE64_CHUNK_SIZE)
+ binary += String.fromCharCode(...chunk)
+ }
+
+ return btoa(binary)
+}
+
+export async function getRenderableAssetUrl(asset: string): Promise {
+ if (!asset || asset.startsWith('data:') || asset.startsWith('http') || asset.startsWith('#') || asset.startsWith('linear-gradient') || asset.startsWith('radial-gradient')) {
+ return asset
+ }
+
+ const resolvedAsset = asset.startsWith('/') && !asset.startsWith('//')
+ ? await getAssetPath(asset.replace(/^\//, ''))
+ : asset
+
+ const localFilePath = toLocalFilePath(resolvedAsset)
+ if (!localFilePath || typeof window === 'undefined' || !window.electronAPI?.readLocalFile) {
+ return resolvedAsset
+ }
+
+ const cached = localFileDataUrlCache.get(resolvedAsset)
+ if (cached) {
+ return cached
+ }
+
+ try {
+ const result = await window.electronAPI.readLocalFile(localFilePath)
+ if (!result.success || !result.data) {
+ return resolvedAsset
+ }
+
+ const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data)
+ const dataUrl = `data:${getMimeTypeForAsset(localFilePath)};base64,${toBase64(bytes)}`
+ localFileDataUrlCache.set(resolvedAsset, dataUrl)
+ return dataUrl
+ } catch {
+ return resolvedAsset
+ }
+}
+
export default getAssetPath;
diff --git a/src/lib/customFonts.ts b/src/lib/customFonts.ts
index f6da8069..45f00bc8 100644
--- a/src/lib/customFonts.ts
+++ b/src/lib/customFonts.ts
@@ -7,13 +7,14 @@ export interface CustomFont {
importUrl: string; // Google Fonts @import URL
}
-const STORAGE_KEY = 'openscreen_custom_fonts';
+const STORAGE_KEY = 'recordly_custom_fonts';
+const LEGACY_STORAGE_KEY = 'openscreen_custom_fonts';
const loadedFonts = new Set();
// Load custom fonts from localStorage
export function getCustomFonts(): CustomFont[] {
try {
- const stored = localStorage.getItem(STORAGE_KEY);
+ const stored = localStorage.getItem(STORAGE_KEY) ?? localStorage.getItem(LEGACY_STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load custom fonts from storage:', error);
@@ -25,6 +26,7 @@ export function getCustomFonts(): CustomFont[] {
export function saveCustomFonts(fonts: CustomFont[]): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(fonts));
+ localStorage.removeItem(LEGACY_STORAGE_KEY);
} catch (error) {
console.error('Failed to save custom fonts to storage:', error);
}
diff --git a/src/lib/exporter/annotationRenderer.ts b/src/lib/exporter/annotationRenderer.ts
index af04e193..06c41217 100644
--- a/src/lib/exporter/annotationRenderer.ts
+++ b/src/lib/exporter/annotationRenderer.ts
@@ -138,7 +138,12 @@ function renderText(
const style = annotation.style;
ctx.save();
-
+
+ // Clip text to annotation box bounds (matches editor's overflow: hidden)
+ ctx.beginPath();
+ ctx.rect(x, y, width, height);
+ ctx.clip();
+
const fontWeight = style.fontWeight === 'bold' ? 'bold' : 'normal';
const fontStyle = style.fontStyle === 'italic' ? 'italic' : 'normal';
const scaledFontSize = style.fontSize * scaleFactor;
@@ -161,7 +166,27 @@ function renderText(
ctx.textAlign = 'left';
}
- const lines = annotation.content.split('\n');
+ const availableWidth = width - containerPadding * 2;
+ const rawLines = annotation.content.split('\n');
+ const lines: string[] = [];
+ for (const rawLine of rawLines) {
+ if (!rawLine) {
+ lines.push('');
+ continue;
+ }
+ const words = rawLine.split(/(\s+)/);
+ let current = '';
+ for (const word of words) {
+ const test = current + word;
+ if (current && ctx.measureText(test).width > availableWidth) {
+ lines.push(current);
+ current = word.trimStart();
+ } else {
+ current = test;
+ }
+ }
+ if (current) lines.push(current);
+ }
const lineHeight = scaledFontSize * 1.4;
const startY = textY - ((lines.length - 1) * lineHeight) / 2;
diff --git a/src/lib/exporter/audioEncoder.ts b/src/lib/exporter/audioEncoder.ts
new file mode 100644
index 00000000..d7e6c1b1
--- /dev/null
+++ b/src/lib/exporter/audioEncoder.ts
@@ -0,0 +1,160 @@
+import type { WebDemuxer } from 'web-demuxer'
+import type { TrimRegion } from '@/components/video-editor/types'
+import type { VideoMuxer } from './muxer'
+
+const AUDIO_BITRATE = 128_000
+const DECODE_BACKPRESSURE_LIMIT = 20
+
+export class AudioProcessor {
+ private cancelled = false
+
+ async process(demuxer: WebDemuxer, muxer: VideoMuxer, trimRegions?: TrimRegion[]): Promise {
+ let audioConfig: AudioDecoderConfig
+ try {
+ audioConfig = (await demuxer.getDecoderConfig('audio')) as AudioDecoderConfig
+ } catch {
+ console.warn('[AudioProcessor] No audio track found, skipping')
+ return
+ }
+
+ const codecCheck = await AudioDecoder.isConfigSupported(audioConfig)
+ if (!codecCheck.supported) {
+ console.warn('[AudioProcessor] Audio codec not supported:', audioConfig.codec)
+ return
+ }
+
+ const sortedTrims = trimRegions ? [...trimRegions].sort((a, b) => a.startMs - b.startMs) : []
+ const decodedFrames: AudioData[] = []
+
+ const decoder = new AudioDecoder({
+ output: (data: AudioData) => decodedFrames.push(data),
+ error: (error: DOMException) => console.error('[AudioProcessor] Decode error:', error),
+ })
+ decoder.configure(audioConfig)
+
+ const reader = (demuxer.read('audio') as ReadableStream).getReader()
+
+ while (!this.cancelled) {
+ const { done, value: chunk } = await reader.read()
+ if (done || !chunk) break
+
+ const timestampMs = chunk.timestamp / 1000
+ if (this.isInTrimRegion(timestampMs, sortedTrims)) continue
+
+ decoder.decode(chunk)
+
+ while (decoder.decodeQueueSize > DECODE_BACKPRESSURE_LIMIT && !this.cancelled) {
+ await new Promise((resolve) => setTimeout(resolve, 1))
+ }
+ }
+
+ if (decoder.state === 'configured') {
+ await decoder.flush()
+ decoder.close()
+ }
+
+ if (this.cancelled || decodedFrames.length === 0) {
+ for (const frame of decodedFrames) frame.close()
+ return
+ }
+
+ const encodedChunks: { chunk: EncodedAudioChunk; meta?: EncodedAudioChunkMetadata }[] = []
+ const encoder = new AudioEncoder({
+ output: (chunk: EncodedAudioChunk, meta?: EncodedAudioChunkMetadata) => {
+ encodedChunks.push({ chunk, meta })
+ },
+ error: (error: DOMException) => console.error('[AudioProcessor] Encode error:', error),
+ })
+
+ const sampleRate = audioConfig.sampleRate || 48_000
+ const channels = audioConfig.numberOfChannels || 2
+ const encodeConfig: AudioEncoderConfig = {
+ codec: 'opus',
+ sampleRate,
+ numberOfChannels: channels,
+ bitrate: AUDIO_BITRATE,
+ }
+
+ const encodeSupport = await AudioEncoder.isConfigSupported(encodeConfig)
+ if (!encodeSupport.supported) {
+ console.warn('[AudioProcessor] Opus encoding not supported, skipping audio')
+ for (const frame of decodedFrames) frame.close()
+ return
+ }
+
+ encoder.configure(encodeConfig)
+
+ for (const audioData of decodedFrames) {
+ if (this.cancelled) {
+ audioData.close()
+ continue
+ }
+
+ const timestampMs = audioData.timestamp / 1000
+ const trimOffsetMs = this.computeTrimOffset(timestampMs, sortedTrims)
+ const adjustedTimestampUs = audioData.timestamp - trimOffsetMs * 1000
+
+ const adjusted = this.cloneWithTimestamp(audioData, Math.max(0, adjustedTimestampUs))
+ audioData.close()
+
+ encoder.encode(adjusted)
+ adjusted.close()
+ }
+
+ if (encoder.state === 'configured') {
+ await encoder.flush()
+ encoder.close()
+ }
+
+ for (const { chunk, meta } of encodedChunks) {
+ if (this.cancelled) break
+ await muxer.addAudioChunk(chunk, meta)
+ }
+ }
+
+ private cloneWithTimestamp(src: AudioData, newTimestamp: number): AudioData {
+ const isPlanar = src.format?.includes('planar') ?? false
+ const numPlanes = isPlanar ? src.numberOfChannels : 1
+
+ let totalSize = 0
+ for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
+ totalSize += src.allocationSize({ planeIndex })
+ }
+
+ const buffer = new ArrayBuffer(totalSize)
+ let offset = 0
+
+ for (let planeIndex = 0; planeIndex < numPlanes; planeIndex++) {
+ const planeSize = src.allocationSize({ planeIndex })
+ src.copyTo(new Uint8Array(buffer, offset, planeSize), { planeIndex })
+ offset += planeSize
+ }
+
+ return new AudioData({
+ format: src.format!,
+ sampleRate: src.sampleRate,
+ numberOfFrames: src.numberOfFrames,
+ numberOfChannels: src.numberOfChannels,
+ timestamp: newTimestamp,
+ data: buffer,
+ })
+ }
+
+ private isInTrimRegion(timestampMs: number, trims: TrimRegion[]) {
+ return trims.some((trim) => timestampMs >= trim.startMs && timestampMs < trim.endMs)
+ }
+
+ private computeTrimOffset(timestampMs: number, trims: TrimRegion[]) {
+ let offset = 0
+ for (const trim of trims) {
+ if (trim.endMs <= timestampMs) {
+ offset += trim.endMs - trim.startMs
+ }
+ }
+ return offset
+ }
+
+ cancel() {
+ this.cancelled = true
+ }
+}
\ No newline at end of file
diff --git a/src/lib/exporter/frameRenderer.ts b/src/lib/exporter/frameRenderer.ts
index 0e7cfe2d..718162a7 100644
--- a/src/lib/exporter/frameRenderer.ts
+++ b/src/lib/exporter/frameRenderer.ts
@@ -1,11 +1,12 @@
import { Application, Container, Sprite, Graphics, BlurFilter, Texture } from 'pixi.js';
-import type { ZoomRegion, CropRegion, AnnotationRegion } from '@/components/video-editor/types';
+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 { findDominantRegion } from '@/components/video-editor/videoPlayback/zoomRegionUtils';
-import { applyZoomTransform } from '@/components/video-editor/videoPlayback/zoomTransform';
-import { DEFAULT_FOCUS, SMOOTHING_FACTOR, MIN_DELTA } from '@/components/video-editor/videoPlayback/constants';
-import { clampFocusToStage as clampFocusToStageUtil } from '@/components/video-editor/videoPlayback/focusUtils';
+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;
@@ -14,22 +15,46 @@ interface FrameRenderConfig {
zoomRegions: ZoomRegion[];
showShadow: boolean;
shadowIntensity: number;
- showBlur: boolean;
- motionBlurEnabled?: boolean;
+ backgroundBlur: number;
+ zoomMotionBlur?: number;
+ connectZooms?: boolean;
borderRadius?: number;
padding?: number;
cropRegion: CropRegion;
videoWidth: number;
videoHeight: number;
annotationRegions?: AnnotationRegion[];
+ speedRegions?: SpeedRegion[];
previewWidth?: number;
previewHeight?: number;
+ cursorTelemetry?: CursorTelemetryPoint[];
+ showCursor?: boolean;
+ cursorSize?: number;
+ cursorSmoothing?: number;
+ cursorMotionBlur?: number;
+ cursorClickBounce?: number;
}
interface AnimationState {
scale: number;
+ appliedScale: number;
focusX: number;
focusY: number;
+ progress: number;
+ x: number;
+ y: number;
+}
+
+function createAnimationState(): AnimationState {
+ return {
+ scale: 1,
+ appliedScale: 1,
+ focusX: DEFAULT_FOCUS.cx,
+ focusY: DEFAULT_FOCUS.cy,
+ progress: 0,
+ x: 0,
+ y: 0,
+ };
}
// Renders video frames with all effects (background, zoom, crop, blur, shadow) to an offscreen canvas for export.
@@ -38,29 +63,39 @@ export class FrameRenderer {
private app: Application | null = null;
private cameraContainer: Container | null = null;
private videoContainer: Container | null = null;
+ private cursorContainer: Container | null = null;
private videoSprite: Sprite | null = null;
private backgroundSprite: Sprite | null = null;
private maskGraphics: Graphics | null = null;
private blurFilter: BlurFilter | null = null;
+ private motionBlurFilter: MotionBlurFilter | null = null;
private shadowCanvas: HTMLCanvasElement | null = null;
private shadowCtx: CanvasRenderingContext2D | null = null;
private compositeCanvas: HTMLCanvasElement | null = null;
private compositeCtx: CanvasRenderingContext2D | null = null;
private config: FrameRenderConfig;
private animationState: AnimationState;
+ private motionBlurState: MotionBlurState;
private layoutCache: any = null;
private currentVideoTime = 0;
+ private lastMotionVector = { x: 0, y: 0 };
+ private cursorOverlay: PixiCursorOverlay | null = null;
constructor(config: FrameRenderConfig) {
this.config = config;
- this.animationState = {
- scale: 1,
- focusX: DEFAULT_FOCUS.cx,
- focusY: DEFAULT_FOCUS.cy,
- };
+ this.animationState = createAnimationState();
+ this.motionBlurState = createMotionBlurState();
}
async initialize(): Promise {
+ let cursorOverlayEnabled = true;
+ try {
+ await preloadCursorAssets();
+ } catch (error) {
+ cursorOverlayEnabled = false;
+ console.warn('[FrameRenderer] Native cursor assets are unavailable; continuing export without cursor overlay.', error);
+ }
+
// Create canvas for rendering
const canvas = document.createElement('canvas');
canvas.width = this.config.width;
@@ -92,8 +127,19 @@ export class FrameRenderer {
// Setup containers
this.cameraContainer = new Container();
this.videoContainer = new Container();
+ this.cursorContainer = new Container();
this.app.stage.addChild(this.cameraContainer);
this.cameraContainer.addChild(this.videoContainer);
+ this.cameraContainer.addChild(this.cursorContainer);
+
+ if (cursorOverlayEnabled) {
+ this.cursorOverlay = new PixiCursorOverlay({
+ 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,
+ });
+ }
// Setup background (render separately, not in PixiJS)
await this.setupBackground();
@@ -103,7 +149,8 @@ export class FrameRenderer {
this.blurFilter.quality = 5;
this.blurFilter.resolution = this.app.renderer.resolution;
this.blurFilter.blur = 0;
- this.videoContainer.filters = [this.blurFilter];
+ this.motionBlurFilter = new MotionBlurFilter([0, 0], 5, 0);
+ this.videoContainer.filters = [this.blurFilter, this.motionBlurFilter];
// Setup composite canvas for final output with shadows
this.compositeCanvas = document.createElement('canvas');
@@ -131,6 +178,9 @@ export class FrameRenderer {
this.maskGraphics = new Graphics();
this.videoContainer.addChild(this.maskGraphics);
this.videoContainer.mask = this.maskGraphics;
+ if (this.cursorOverlay) {
+ this.cursorContainer.addChild(this.cursorOverlay.container);
+ }
}
private async setupBackground(): Promise {
@@ -262,6 +312,12 @@ export class FrameRenderer {
const texture = Texture.from(videoFrame as any);
this.videoSprite = new Sprite(texture);
this.videoContainer.addChild(this.videoSprite);
+ if (this.cursorOverlay && this.cursorContainer) {
+ this.cursorContainer.addChild(this.cursorOverlay.container);
+ }
+ if (this.maskGraphics) {
+ this.videoContainer.addChild(this.maskGraphics);
+ }
} else {
// Destroy old texture to avoid memory leaks, then create new one
const oldTexture = this.videoSprite.texture;
@@ -274,6 +330,17 @@ export class FrameRenderer {
this.updateLayout();
const timeMs = this.currentVideoTime * 1000;
+
+ if (this.cursorOverlay) {
+ this.cursorOverlay.update(
+ this.config.cursorTelemetry ?? [],
+ timeMs,
+ this.layoutCache.maskRect,
+ this.config.showCursor ?? true,
+ false,
+ );
+ }
+
const TICKS_PER_FRAME = 1;
let maxMotionIntensity = 0;
@@ -286,14 +353,24 @@ export class FrameRenderer {
applyZoomTransform({
cameraContainer: this.cameraContainer,
blurFilter: this.blurFilter,
+ motionBlurFilter: this.motionBlurFilter,
stageSize: this.layoutCache.stageSize,
baseMask: this.layoutCache.maskRect,
zoomScale: this.animationState.scale,
+ zoomProgress: this.animationState.progress,
focusX: this.animationState.focusX,
focusY: this.animationState.focusY,
motionIntensity: maxMotionIntensity,
+ motionVector: this.lastMotionVector,
isPlaying: true,
- motionBlurEnabled: this.config.motionBlurEnabled ?? false,
+ motionBlurAmount: this.config.zoomMotionBlur ?? 0,
+ transformOverride: {
+ scale: this.animationState.appliedScale,
+ x: this.animationState.x,
+ y: this.animationState.y,
+ },
+ motionBlurState: this.motionBlurState,
+ frameTimeMs: timeMs,
});
// Render the PixiJS stage to its canvas (video only, transparent background)
@@ -320,6 +397,7 @@ export class FrameRenderer {
scaleFactor
);
}
+
}
private updateLayout(): void {
@@ -346,22 +424,20 @@ export class FrameRenderer {
const viewportHeight = height * paddingScale;
const scale = Math.min(viewportWidth / croppedVideoWidth, viewportHeight / croppedVideoHeight);
- // Position video sprite
- this.videoSprite.width = videoWidth * scale;
- this.videoSprite.height = videoHeight * scale;
+ this.videoSprite.scale.set(scale);
- const cropPixelX = cropStartX * videoWidth * scale;
- const cropPixelY = cropStartY * videoHeight * scale;
- this.videoSprite.x = -cropPixelX;
- this.videoSprite.y = -cropPixelY;
-
- // Position video container
+ const fullVideoDisplayWidth = videoWidth * scale;
+ const fullVideoDisplayHeight = videoHeight * scale;
const croppedDisplayWidth = croppedVideoWidth * scale;
const croppedDisplayHeight = croppedVideoHeight * scale;
const centerOffsetX = (width - croppedDisplayWidth) / 2;
const centerOffsetY = (height - croppedDisplayHeight) / 2;
- this.videoContainer.x = centerOffsetX;
- this.videoContainer.y = centerOffsetY;
+
+ const spriteX = centerOffsetX - (cropRegion.x * fullVideoDisplayWidth);
+ const spriteY = centerOffsetY - (cropRegion.y * fullVideoDisplayHeight);
+ this.videoSprite.position.set(spriteX, spriteY);
+
+ this.videoContainer.position.set(0, 0);
// scale border radius by export/preview canvas ratio
const previewWidth = this.config.previewWidth || 1920;
@@ -370,7 +446,7 @@ export class FrameRenderer {
const scaledBorderRadius = borderRadius * canvasScaleFactor;
this.maskGraphics.clear();
- this.maskGraphics.roundRect(0, 0, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
+ this.maskGraphics.roundRect(centerOffsetX, centerOffsetY, croppedDisplayWidth, croppedDisplayHeight, scaledBorderRadius);
this.maskGraphics.fill({ color: 0xffffff });
// Cache layout info
@@ -378,76 +454,106 @@ export class FrameRenderer {
stageSize: { width, height },
videoSize: { width: croppedVideoWidth, height: croppedVideoHeight },
baseScale: scale,
- baseOffset: { x: centerOffsetX, y: centerOffsetY },
- maskRect: { x: 0, y: 0, width: croppedDisplayWidth, height: croppedDisplayHeight },
+ baseOffset: { x: spriteX, y: spriteY },
+ maskRect: { x: centerOffsetX, y: centerOffsetY, width: croppedDisplayWidth, height: croppedDisplayHeight },
};
}
- private clampFocusToStage(focus: { cx: number; cy: number }, depth: number): { cx: number; cy: number } {
- if (!this.layoutCache) return focus;
- return clampFocusToStageUtil(focus, depth as any, this.layoutCache);
- }
-
private updateAnimationState(timeMs: number): number {
if (!this.cameraContainer || !this.layoutCache) return 0;
- const { region, strength } = findDominantRegion(this.config.zoomRegions, timeMs);
+ const { region, strength, blendedScale, transition } = findDominantRegion(this.config.zoomRegions, timeMs, {
+ connectZooms: this.config.connectZooms,
+ });
const defaultFocus = DEFAULT_FOCUS;
let targetScaleFactor = 1;
let targetFocus = { ...defaultFocus };
+ let targetProgress = 0;
if (region && strength > 0) {
- const zoomScale = ZOOM_DEPTH_SCALES[region.depth];
- const regionFocus = this.clampFocusToStage(region.focus, region.depth);
+ const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
+ const regionFocus = region.focus;
- targetScaleFactor = 1 + (zoomScale - 1) * strength;
- targetFocus = {
- cx: defaultFocus.cx + (regionFocus.cx - defaultFocus.cx) * strength,
- cy: defaultFocus.cy + (regionFocus.cy - defaultFocus.cy) * strength,
- };
+ targetScaleFactor = zoomScale;
+ targetFocus = regionFocus;
+ targetProgress = strength;
+
+ if (transition) {
+ const startTransform = computeZoomTransform({
+ stageSize: this.layoutCache.stageSize,
+ baseMask: this.layoutCache.maskRect,
+ zoomScale: transition.startScale,
+ zoomProgress: 1,
+ focusX: transition.startFocus.cx,
+ focusY: transition.startFocus.cy,
+ });
+ const endTransform = computeZoomTransform({
+ stageSize: this.layoutCache.stageSize,
+ baseMask: this.layoutCache.maskRect,
+ zoomScale: transition.endScale,
+ zoomProgress: 1,
+ focusX: transition.endFocus.cx,
+ focusY: transition.endFocus.cy,
+ });
+
+ 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,
+ };
+
+ targetScaleFactor = interpolatedTransform.scale;
+ targetFocus = computeFocusFromTransform({
+ stageSize: this.layoutCache.stageSize,
+ baseMask: this.layoutCache.maskRect,
+ zoomScale: interpolatedTransform.scale,
+ x: interpolatedTransform.x,
+ y: interpolatedTransform.y,
+ });
+ targetProgress = 1;
+ }
}
const state = this.animationState;
- const prevScale = state.scale;
- const prevFocusX = state.focusX;
- const prevFocusY = state.focusY;
+ const prevScale = state.appliedScale;
+ const prevX = state.x;
+ const prevY = state.y;
- const scaleDelta = targetScaleFactor - state.scale;
- const focusXDelta = targetFocus.cx - state.focusX;
- const focusYDelta = targetFocus.cy - state.focusY;
+ state.scale = targetScaleFactor;
+ state.focusX = targetFocus.cx;
+ state.focusY = targetFocus.cy;
+ state.progress = targetProgress;
- let nextScale = prevScale;
- let nextFocusX = prevFocusX;
- let nextFocusY = prevFocusY;
+ const projectedTransform = computeZoomTransform({
+ stageSize: this.layoutCache.stageSize,
+ baseMask: this.layoutCache.maskRect,
+ zoomScale: state.scale,
+ zoomProgress: state.progress,
+ focusX: state.focusX,
+ focusY: state.focusY,
+ });
- if (Math.abs(scaleDelta) > MIN_DELTA) {
- nextScale = prevScale + scaleDelta * SMOOTHING_FACTOR;
- } else {
- nextScale = targetScaleFactor;
- }
+ 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;
- if (Math.abs(focusXDelta) > MIN_DELTA) {
- nextFocusX = prevFocusX + focusXDelta * SMOOTHING_FACTOR;
- } else {
- nextFocusX = targetFocus.cx;
- }
-
- if (Math.abs(focusYDelta) > MIN_DELTA) {
- nextFocusY = prevFocusY + focusYDelta * SMOOTHING_FACTOR;
- } else {
- nextFocusY = targetFocus.cy;
- }
-
- state.scale = nextScale;
- state.focusX = nextFocusX;
- state.focusY = nextFocusY;
+ this.lastMotionVector = {
+ x: state.x - prevX,
+ y: state.y - prevY,
+ };
return Math.max(
- Math.abs(nextScale - prevScale),
- Math.abs(nextFocusX - prevFocusX),
- Math.abs(nextFocusY - prevFocusY)
+ 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)
);
}
@@ -466,9 +572,9 @@ export class FrameRenderer {
if (this.backgroundSprite) {
const bgCanvas = this.backgroundSprite as any as HTMLCanvasElement;
- if (this.config.showBlur) {
+ if (this.config.backgroundBlur > 0) {
ctx.save();
- ctx.filter = 'blur(6px)'; // Canvas blur is weaker than CSS
+ ctx.filter = `blur(${this.config.backgroundBlur * 3}px)`;
ctx.drawImage(bgCanvas, 0, 0, w, h);
ctx.restore();
} else {
@@ -513,18 +619,25 @@ export class FrameRenderer {
destroy(): void {
if (this.videoSprite) {
- this.videoSprite.destroy();
+ const videoTexture = this.videoSprite.texture;
+ this.videoSprite.destroy({ texture: false, textureSource: false });
+ videoTexture?.destroy(true);
this.videoSprite = null;
}
this.backgroundSprite = null;
if (this.app) {
- this.app.destroy(true, { children: true, texture: true, textureSource: true });
+ this.app.destroy(true, { children: true, texture: false, textureSource: false });
this.app = null;
}
this.cameraContainer = null;
this.videoContainer = null;
this.maskGraphics = null;
this.blurFilter = null;
+ this.motionBlurFilter = null;
+ if (this.cursorOverlay) {
+ this.cursorOverlay.destroy();
+ this.cursorOverlay = null;
+ }
this.shadowCanvas = null;
this.shadowCtx = null;
this.compositeCanvas = null;
diff --git a/src/lib/exporter/gifExporter.ts b/src/lib/exporter/gifExporter.ts
index bf7a5f4b..dee95ba7 100644
--- a/src/lib/exporter/gifExporter.ts
+++ b/src/lib/exporter/gifExporter.ts
@@ -2,7 +2,7 @@ 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 } from '@/components/video-editor/types';
+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();
@@ -16,15 +16,23 @@ interface GifExporterConfig {
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
+ speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
- showBlur: boolean;
- motionBlurEnabled?: boolean;
+ 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;
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
@@ -92,16 +100,24 @@ export class GifExporter {
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
- showBlur: this.config.showBlur,
- motionBlurEnabled: this.config.motionBlurEnabled,
+ 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();
@@ -122,7 +138,7 @@ export class GifExporter {
});
// Calculate effective duration and frame count (excluding trim regions)
- const effectiveDuration = this.streamingDecoder.getEffectiveDuration(this.config.trimRegions);
+ 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)
@@ -142,34 +158,20 @@ export class GifExporter {
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
+ this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
return;
}
- // Render the frame with all effects using source timestamp
- const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
+ const sourceTimestampUs = sourceTimestampMs * 1000;
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
- // Get the rendered canvas and add to GIF
- const canvas = this.renderer!.getCanvas();
-
- // Add frame to GIF encoder with delay
- this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
-
+ this.addRenderedGifFrame(frameDelay);
frameIndex++;
-
- // Update progress
- if (this.config.onProgress) {
- this.config.onProgress({
- currentFrame: frameIndex,
- totalFrames,
- percentage: (frameIndex / totalFrames) * 100,
- estimatedTimeRemaining: 0,
- });
- }
+ this.reportProgress(frameIndex, totalFrames);
}
);
@@ -224,6 +226,22 @@ export class GifExporter {
}
}
+ private addRenderedGifFrame(frameDelay: number) {
+ const canvas = this.renderer!.getCanvas();
+ this.gif!.addFrame(canvas, { delay: frameDelay, copy: true });
+ }
+
+ private reportProgress(currentFrame: number, totalFrames: number) {
+ if (this.config.onProgress) {
+ this.config.onProgress({
+ currentFrame,
+ totalFrames,
+ percentage: totalFrames > 0 ? (currentFrame / totalFrames) * 100 : 100,
+ estimatedTimeRemaining: 0,
+ });
+ }
+ }
+
cancel(): void {
this.cancelled = true;
if (this.streamingDecoder) {
diff --git a/src/lib/exporter/muxer.ts b/src/lib/exporter/muxer.ts
index dd07f789..42dd6cad 100644
--- a/src/lib/exporter/muxer.ts
+++ b/src/lib/exporter/muxer.ts
@@ -27,7 +27,7 @@ export class VideoMuxer {
this.output = new Output({
format: new Mp4OutputFormat({
- fastStart: 'in-memory',
+ fastStart: false,
}),
target: this.target,
});
diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts
index d5610fc9..39118175 100644
--- a/src/lib/exporter/streamingDecoder.ts
+++ b/src/lib/exporter/streamingDecoder.ts
@@ -1,5 +1,5 @@
import { WebDemuxer } from 'web-demuxer';
-import type { TrimRegion } from '@/components/video-editor/types';
+import type { TrimRegion, SpeedRegion } from '@/components/video-editor/types';
export interface DecodedVideoInfo {
width: number;
@@ -7,6 +7,8 @@ export interface DecodedVideoInfo {
duration: number; // seconds
frameRate: number;
codec: string;
+ hasAudio: boolean;
+ audioCodec?: string;
}
/** Caller must close the VideoFrame after use. */
@@ -29,19 +31,88 @@ export class StreamingVideoDecoder {
private cancelled = false;
private metadata: DecodedVideoInfo | null = null;
- async loadMetadata(videoUrl: string): Promise {
- const response = await fetch(videoUrl);
+ private toLocalFilePath(resourceUrl: string): string | null {
+ if (!resourceUrl.startsWith('file:')) {
+ return null;
+ }
+
+ try {
+ const url = new URL(resourceUrl);
+ let filePath = decodeURIComponent(url.pathname);
+ if (/^\/[A-Za-z]:/.test(filePath)) {
+ filePath = filePath.slice(1);
+ }
+ return filePath;
+ } catch {
+ return resourceUrl.replace(/^file:\/\//, '');
+ }
+ }
+
+ private inferMimeType(fileName: string): string {
+ const extension = fileName.split('.').pop()?.toLowerCase();
+ switch (extension) {
+ case 'mov':
+ return 'video/quicktime';
+ case 'webm':
+ return 'video/webm';
+ case 'mkv':
+ return 'video/x-matroska';
+ case 'avi':
+ return 'video/x-msvideo';
+ case 'mp4':
+ default:
+ return 'video/mp4';
+ }
+ }
+
+ private async loadVideoFile(resourceUrl: string): Promise {
+ const filename = resourceUrl.split('/').pop() || 'video';
+ const localFilePath = this.toLocalFilePath(resourceUrl);
+
+ if (localFilePath) {
+ const result = await window.electronAPI.readLocalFile(localFilePath);
+ if (!result.success || !result.data) {
+ throw new Error(result.error || 'Failed to read local video file');
+ }
+
+ const bytes = result.data instanceof Uint8Array ? result.data : new Uint8Array(result.data);
+ const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
+ return new File([arrayBuffer], filename, { type: this.inferMimeType(filename) });
+ }
+
+ const response = await fetch(resourceUrl);
+ if (!response.ok) {
+ throw new Error(`Failed to load video resource: ${response.status} ${response.statusText}`);
+ }
+
const blob = await response.blob();
- const filename = videoUrl.split('/').pop() || 'video';
- const file = new File([blob], filename, { type: blob.type });
+ return new File([blob], filename, { type: blob.type || this.inferMimeType(filename) });
+ }
+
+ private resolveVideoResourceUrl(videoUrl: string): string {
+ if (/^(blob:|data:|https?:|file:)/i.test(videoUrl)) {
+ return videoUrl;
+ }
+
+ if (videoUrl.startsWith('/')) {
+ return `file://${encodeURI(videoUrl)}`;
+ }
+
+ return videoUrl;
+ }
+
+ async loadMetadata(videoUrl: string): Promise {
+ const resourceUrl = this.resolveVideoResourceUrl(videoUrl);
// Relative URL so it resolves correctly in both dev (http) and packaged (file://) builds
const wasmUrl = new URL('./wasm/web-demuxer.wasm', window.location.href).href;
this.demuxer = new WebDemuxer({ wasmFilePath: wasmUrl });
+ const file = await this.loadVideoFile(resourceUrl);
await this.demuxer.load(file);
const mediaInfo = await this.demuxer.getMediaInfo();
const videoStream = mediaInfo.streams.find(s => s.codec_type_string === 'video');
+ const audioStream = mediaInfo.streams.find(s => s.codec_type_string === 'audio');
let frameRate = 60;
if (videoStream?.avg_frame_rate) {
@@ -59,6 +130,8 @@ export class StreamingVideoDecoder {
duration: mediaInfo.duration,
frameRate,
codec: videoStream?.codec_string || 'unknown',
+ hasAudio: !!audioStream,
+ audioCodec: audioStream?.codec_string,
};
return this.metadata;
@@ -67,6 +140,7 @@ export class StreamingVideoDecoder {
async decodeAll(
targetFrameRate: number,
trimRegions: TrimRegion[] | undefined,
+ speedRegions: SpeedRegion[] | undefined,
onFrame: OnFrameCallback
): Promise {
if (!this.demuxer || !this.metadata) {
@@ -74,7 +148,10 @@ export class StreamingVideoDecoder {
}
const decoderConfig = await this.demuxer.getDecoderConfig('video');
- const segments = this.computeSegments(this.metadata.duration, trimRegions);
+ const segments = this.splitBySpeed(
+ this.computeSegments(this.metadata.duration, trimRegions),
+ speedRegions
+ );
const frameDurationUs = 1_000_000 / targetFrameRate;
// Async frame queue — decoder pushes, consumer pulls
@@ -215,10 +292,12 @@ export class StreamingVideoDecoder {
/**
* Resample buffered frames to fill the target frame count for this segment.
* Handles VFR sources by duplicating/decimating as needed.
+ * Uses interpolated source timestamps so animations advance smoothly
+ * even when multiple export frames map to the same source frame.
*/
private async deliverSegment(
frames: VideoFrame[],
- segment: { startSec: number; endSec: number },
+ segment: { startSec: number; endSec: number; speed: number },
targetFrameRate: number,
frameDurationUs: number,
startExportFrameIndex: number,
@@ -226,7 +305,10 @@ export class StreamingVideoDecoder {
): Promise {
if (frames.length === 0) return startExportFrameIndex;
- const segmentFrameCount = Math.ceil((segment.endSec - segment.startSec) * targetFrameRate);
+ const segmentDuration = segment.endSec - segment.startSec;
+ const segmentFrameCount = Math.ceil(
+ segmentDuration / segment.speed * targetFrameRate
+ );
let exportFrameIndex = startExportFrameIndex;
for (let i = 0; i < segmentFrameCount && !this.cancelled; i++) {
@@ -235,8 +317,12 @@ export class StreamingVideoDecoder {
frames.length - 1
);
const sourceFrame = frames[sourceIdx];
+ // Compute a uniformly-spaced source timestamp so zoom/cursor animations
+ // progress smoothly instead of stalling on duplicate VFR timestamps
+ const t = segmentFrameCount > 1 ? i / segmentFrameCount : 0;
+ const interpolatedSourceTimeMs = (segment.startSec + t * segmentDuration) * 1000;
const clone = new VideoFrame(sourceFrame, { timestamp: sourceFrame.timestamp });
- await onFrame(clone, exportFrameIndex * frameDurationUs, sourceFrame.timestamp / 1000);
+ await onFrame(clone, exportFrameIndex * frameDurationUs, interpolatedSourceTimeMs);
exportFrameIndex++;
}
@@ -271,18 +357,49 @@ export class StreamingVideoDecoder {
return segments;
}
- getEffectiveDuration(trimRegions?: TrimRegion[]): number {
+ getEffectiveDuration(trimRegions?: TrimRegion[], speedRegions?: SpeedRegion[]): number {
if (!this.metadata) throw new Error('Must call loadMetadata() first');
- const trimmed = (trimRegions || []).reduce(
- (sum, r) => sum + (r.endMs - r.startMs) / 1000, 0
- );
- return this.metadata.duration - trimmed;
+ const trimSegments = this.computeSegments(this.metadata.duration, trimRegions);
+ const speedSegments = this.splitBySpeed(trimSegments, speedRegions);
+ return speedSegments.reduce((sum, seg) => sum + (seg.endSec - seg.startSec) / seg.speed, 0);
+ }
+
+ private splitBySpeed(
+ segments: Array<{ startSec: number; endSec: number }>,
+ speedRegions?: SpeedRegion[]
+ ): Array<{ startSec: number; endSec: number; speed: number }> {
+ if (!speedRegions || speedRegions.length === 0)
+ return segments.map(s => ({ ...s, speed: 1 }));
+
+ const result: Array<{ startSec: number; endSec: number; speed: number }> = [];
+ for (const segment of segments) {
+ const overlapping = speedRegions
+ .filter(sr => (sr.startMs / 1000) < segment.endSec && (sr.endMs / 1000) > segment.startSec)
+ .sort((a, b) => a.startMs - b.startMs);
+
+ if (overlapping.length === 0) { result.push({ ...segment, speed: 1 }); continue; }
+
+ let cursor = segment.startSec;
+ for (const sr of overlapping) {
+ const srStart = Math.max(sr.startMs / 1000, segment.startSec);
+ const srEnd = Math.min(sr.endMs / 1000, segment.endSec);
+ if (cursor < srStart) result.push({ startSec: cursor, endSec: srStart, speed: 1 });
+ result.push({ startSec: srStart, endSec: srEnd, speed: sr.speed });
+ cursor = srEnd;
+ }
+ if (cursor < segment.endSec) result.push({ startSec: cursor, endSec: segment.endSec, speed: 1 });
+ }
+ return result.filter(s => s.endSec - s.startSec > 0.0001);
}
cancel(): void {
this.cancelled = true;
}
+ getDemuxer() {
+ return this.demuxer;
+ }
+
destroy(): void {
this.cancelled = true;
diff --git a/src/lib/exporter/videoExporter.ts b/src/lib/exporter/videoExporter.ts
index 937c4a37..1c6763d1 100644
--- a/src/lib/exporter/videoExporter.ts
+++ b/src/lib/exporter/videoExporter.ts
@@ -1,23 +1,32 @@
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 } from '@/components/video-editor/types';
+import type { ZoomRegion, CropRegion, TrimRegion, AnnotationRegion, SpeedRegion, CursorTelemetryPoint } from '@/components/video-editor/types';
interface VideoExporterConfig extends ExportConfig {
videoUrl: string;
wallpaper: string;
zoomRegions: ZoomRegion[];
trimRegions?: TrimRegion[];
+ speedRegions?: SpeedRegion[];
showShadow: boolean;
shadowIntensity: number;
- showBlur: boolean;
- motionBlurEnabled?: boolean;
+ 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;
previewWidth?: number;
previewHeight?: number;
onProgress?: (progress: ExportProgress) => void;
@@ -29,14 +38,14 @@ export class VideoExporter {
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;
- // Track muxing promises for parallel processing
- private muxingPromises: Promise[] = [];
+ private pendingMuxing: Promise = Promise.resolve();
private chunkCount = 0;
constructor(config: VideoExporterConfig) {
@@ -60,28 +69,38 @@ export class VideoExporter {
zoomRegions: this.config.zoomRegions,
showShadow: this.config.showShadow,
shadowIntensity: this.config.shadowIntensity,
- showBlur: this.config.showBlur,
- motionBlurEnabled: this.config.motionBlurEnabled,
+ 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 video encoder
await this.initializeEncoder();
+ const hasAudio = videoInfo.hasAudio;
+
// Initialize muxer
- this.muxer = new VideoMuxer(this.config, false);
+ 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);
+ 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');
@@ -96,6 +115,7 @@ export class VideoExporter {
await this.streamingDecoder.decodeAll(
this.config.frameRate,
this.config.trimRegions,
+ this.config.speedRegions,
async (videoFrame, _exportTimestampUs, sourceTimestampMs) => {
if (this.cancelled) {
videoFrame.close();
@@ -103,52 +123,13 @@ export class VideoExporter {
}
const timestamp = frameIndex * frameDuration;
-
- // Render the frame with all effects using source timestamp
- const sourceTimestampUs = sourceTimestampMs * 1000; // Convert to microseconds
+ const sourceTimestampUs = sourceTimestampMs * 1000;
await this.renderer!.renderFrame(videoFrame, sourceTimestampUs);
videoFrame.close();
- const canvas = this.renderer!.getCanvas();
-
- // Create VideoFrame from canvas on GPU without reading pixels
- // @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,
- },
- });
-
- // Check encoder queue before encoding to keep it full
- while (this.encodeQueue >= this.MAX_ENCODE_QUEUE && !this.cancelled) {
- await new Promise(resolve => setTimeout(resolve, 0));
- }
-
- 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();
-
+ await this.encodeRenderedFrame(timestamp, frameDuration, frameIndex);
frameIndex++;
-
- // Update progress
- if (this.config.onProgress) {
- this.config.onProgress({
- currentFrame: frameIndex,
- totalFrames,
- percentage: (frameIndex / totalFrames) * 100,
- estimatedTimeRemaining: 0,
- });
- }
+ this.reportProgress(frameIndex, totalFrames);
}
);
@@ -161,8 +142,16 @@ export class VideoExporter {
await this.encoder.flush();
}
- // Wait for all muxing operations to complete
- await Promise.all(this.muxingPromises);
+ // Wait for queued muxing operations to complete
+ await this.pendingMuxing;
+
+ if (hasAudio && !this.cancelled) {
+ const demuxer = this.streamingDecoder.getDemuxer();
+ if (demuxer) {
+ this.audioProcessor = new AudioProcessor();
+ await this.audioProcessor.process(demuxer, this.muxer!, this.config.trimRegions);
+ }
+ }
// Finalize muxer and get output blob
const blob = await this.muxer!.finalize();
@@ -179,9 +168,49 @@ export class VideoExporter {
}
}
+ 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,
+ },
+ });
+
+ 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}`);
+ }
+
+ 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 async initializeEncoder(): Promise {
this.encodeQueue = 0;
- this.muxingPromises = [];
+ this.pendingMuxing = Promise.resolve();
this.chunkCount = 0;
let videoDescription: Uint8Array | undefined;
@@ -198,11 +227,11 @@ export class VideoExporter {
this.videoColorSpace = meta.decoderConfig.colorSpace;
}
- // Stream chunk to muxer immediately (parallel processing)
+ // Stream chunks to muxer in order without retaining an ever-growing promise array
const isFirstChunk = this.chunkCount === 0;
this.chunkCount++;
- const muxingPromise = (async () => {
+ this.pendingMuxing = this.pendingMuxing.then(async () => {
try {
if (isFirstChunk && this.videoDescription) {
// Add decoder config for the first chunk
@@ -230,9 +259,7 @@ export class VideoExporter {
} catch (error) {
console.error('Muxing error:', error);
}
- })();
-
- this.muxingPromises.push(muxingPromise);
+ });
this.encodeQueue--;
},
error: (error) => {
@@ -250,7 +277,7 @@ export class VideoExporter {
height: this.config.height,
bitrate: this.config.bitrate,
framerate: this.config.frameRate,
- latencyMode: 'realtime',
+ latencyMode: 'quality', // Changed from 'realtime' to 'quality' for better throughput
bitrateMode: 'variable',
hardwareAcceleration: 'prefer-hardware',
};
@@ -281,6 +308,9 @@ export class VideoExporter {
if (this.streamingDecoder) {
this.streamingDecoder.cancel();
}
+ if (this.audioProcessor) {
+ this.audioProcessor.cancel();
+ }
this.cleanup();
}
@@ -315,8 +345,9 @@ export class VideoExporter {
}
this.muxer = null;
+ this.audioProcessor = null;
this.encodeQueue = 0;
- this.muxingPromises = [];
+ this.pendingMuxing = Promise.resolve();
this.chunkCount = 0;
this.videoDescription = undefined;
this.videoColorSpace = undefined;
diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts
new file mode 100644
index 00000000..847c7539
--- /dev/null
+++ b/src/lib/shortcuts.ts
@@ -0,0 +1,125 @@
+export const SHORTCUT_ACTIONS = [
+ 'addZoom',
+ 'addTrim',
+ 'addSpeed',
+ 'addAnnotation',
+ 'addKeyframe',
+ 'deleteSelected',
+ 'playPause',
+] as const;
+
+export type ShortcutAction = (typeof SHORTCUT_ACTIONS)[number];
+
+export interface ShortcutBinding {
+ key: string;
+ /** Maps to Cmd on macOS, Ctrl on Windows/Linux */
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+}
+
+export type ShortcutsConfig = Record;
+
+export interface FixedShortcut {
+ label: string;
+ display: string;
+ bindings: ShortcutBinding[];
+}
+
+export const FIXED_SHORTCUTS: FixedShortcut[] = [
+ { label: 'Cycle Annotations Forward', display: 'Tab', bindings: [{ key: 'tab' }] },
+ { label: 'Cycle Annotations Backward', display: 'Shift + Tab', bindings: [{ key: 'tab', shift: true }] },
+ { label: 'Delete Selected (alt)', display: 'Del / ⌫', bindings: [{ key: 'delete' }, { key: 'backspace' }] },
+ { label: 'Pan Timeline', display: 'Shift + Ctrl + Scroll', bindings: [] },
+ { label: 'Zoom Timeline', display: 'Ctrl + Scroll', bindings: [] },
+];
+
+export type ShortcutConflict =
+ | { type: 'configurable'; action: ShortcutAction }
+ | { type: 'fixed'; label: string };
+
+export function bindingsEqual(a: ShortcutBinding, b: ShortcutBinding): boolean {
+ return (
+ a.key.toLowerCase() === b.key.toLowerCase() &&
+ !!a.ctrl === !!b.ctrl &&
+ !!a.shift === !!b.shift &&
+ !!a.alt === !!b.alt
+ );
+}
+
+export function findConflict(
+ binding: ShortcutBinding,
+ forAction: ShortcutAction,
+ config: ShortcutsConfig,
+): ShortcutConflict | null {
+ for (const fixed of FIXED_SHORTCUTS) {
+ if (fixed.bindings.some((b) => bindingsEqual(b, binding))) {
+ return { type: 'fixed', label: fixed.label };
+ }
+ }
+ for (const action of SHORTCUT_ACTIONS) {
+ if (action !== forAction && bindingsEqual(config[action], binding)) {
+ return { type: 'configurable', action };
+ }
+ }
+ return null;
+}
+
+export const DEFAULT_SHORTCUTS: ShortcutsConfig = {
+ addZoom: { key: 'z' },
+ addTrim: { key: 't' },
+ addSpeed: { key: 's' },
+ addAnnotation: { key: 'a' },
+ addKeyframe: { key: 'f' },
+ deleteSelected: { key: 'd', ctrl: true },
+ playPause: { key: ' ' },
+};
+
+export const SHORTCUT_LABELS: Record = {
+ addZoom: 'Add Zoom',
+ addTrim: 'Add Trim',
+ addSpeed: 'Add Speed',
+ addAnnotation: 'Add Annotation',
+ addKeyframe: 'Add Keyframe',
+ deleteSelected: 'Delete Selected',
+ playPause: 'Play / Pause',
+};
+
+export function matchesShortcut(
+ e: KeyboardEvent,
+ binding: ShortcutBinding,
+ isMacPlatform: boolean,
+): boolean {
+ if (e.key.toLowerCase() !== binding.key.toLowerCase()) return false;
+
+ const primaryMod = isMacPlatform ? e.metaKey : e.ctrlKey;
+ if (primaryMod !== !!binding.ctrl) return false;
+ if (e.shiftKey !== !!binding.shift) return false;
+ if (e.altKey !== !!binding.alt) return false;
+
+ return true;
+}
+
+const KEY_LABELS: Record = {
+ ' ': 'Space', 'delete': 'Del', 'backspace': '⌫', 'escape': 'Esc',
+ 'arrowup': '↑', 'arrowdown': '↓', 'arrowleft': '←', 'arrowright': '→',
+};
+
+export function formatBinding(binding: ShortcutBinding, isMac: boolean): string {
+ const parts: string[] = [];
+ if (binding.ctrl) parts.push(isMac ? '⌘' : 'Ctrl');
+ if (binding.shift) parts.push(isMac ? '⇧' : 'Shift');
+ if (binding.alt) parts.push(isMac ? '⌥' : 'Alt');
+ parts.push(KEY_LABELS[binding.key] ?? binding.key.toUpperCase());
+ return parts.join(' + ');
+}
+
+export function mergeWithDefaults(partial: Partial): ShortcutsConfig {
+ const merged = { ...DEFAULT_SHORTCUTS };
+ for (const action of SHORTCUT_ACTIONS) {
+ if (partial[action]) {
+ merged[action] = partial[action] as ShortcutBinding;
+ }
+ }
+ return merged;
+}
diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts
new file mode 100644
index 00000000..651f8d5b
--- /dev/null
+++ b/src/lib/wallpapers.ts
@@ -0,0 +1,37 @@
+export interface BuiltInWallpaper {
+ id: string;
+ label: string;
+ relativePath: string;
+ publicPath: string;
+}
+
+export const BUILT_IN_WALLPAPERS: BuiltInWallpaper[] = [
+ { id: 'wallpaper-1', label: 'Wallpaper 1', relativePath: 'wallpapers/wallpaper1.jpg', publicPath: '/wallpapers/wallpaper1.jpg' },
+ { id: 'wallpaper-2', label: 'Wallpaper 2', relativePath: 'wallpapers/wallpaper2.jpg', publicPath: '/wallpapers/wallpaper2.jpg' },
+ { id: 'wallpaper-3', label: 'Wallpaper 3', relativePath: 'wallpapers/wallpaper3.jpg', publicPath: '/wallpapers/wallpaper3.jpg' },
+ { id: 'wallpaper-4', label: 'Wallpaper 4', relativePath: 'wallpapers/wallpaper4.jpg', publicPath: '/wallpapers/wallpaper4.jpg' },
+ { id: 'wallpaper-5', label: 'Wallpaper 5', relativePath: 'wallpapers/wallpaper5.jpg', publicPath: '/wallpapers/wallpaper5.jpg' },
+ { id: 'wallpaper-6', label: 'Wallpaper 6', relativePath: 'wallpapers/wallpaper6.jpg', publicPath: '/wallpapers/wallpaper6.jpg' },
+ { id: 'wallpaper-7', label: 'Wallpaper 7', relativePath: 'wallpapers/wallpaper7.jpg', publicPath: '/wallpapers/wallpaper7.jpg' },
+ { id: 'wallpaper-8', label: 'Wallpaper 8', relativePath: 'wallpapers/wallpaper8.jpg', publicPath: '/wallpapers/wallpaper8.jpg' },
+ { id: 'wallpaper-9', label: 'Wallpaper 9', relativePath: 'wallpapers/wallpaper9.jpg', publicPath: '/wallpapers/wallpaper9.jpg' },
+ { id: 'wallpaper-10', label: 'Wallpaper 10', relativePath: 'wallpapers/wallpaper10.jpg', publicPath: '/wallpapers/wallpaper10.jpg' },
+ { id: 'wallpaper-11', label: 'Wallpaper 11', relativePath: 'wallpapers/wallpaper11.jpg', publicPath: '/wallpapers/wallpaper11.jpg' },
+ { id: 'wallpaper-12', label: 'Wallpaper 12', relativePath: 'wallpapers/wallpaper12.jpg', publicPath: '/wallpapers/wallpaper12.jpg' },
+ { id: 'wallpaper-13', label: 'Wallpaper 13', relativePath: 'wallpapers/wallpaper13.jpg', publicPath: '/wallpapers/wallpaper13.jpg' },
+ { id: 'wallpaper-14', label: 'Wallpaper 14', relativePath: 'wallpapers/wallpaper14.jpg', publicPath: '/wallpapers/wallpaper14.jpg' },
+ { id: 'wallpaper-15', label: 'Wallpaper 15', relativePath: 'wallpapers/wallpaper15.jpg', publicPath: '/wallpapers/wallpaper15.jpg' },
+ { id: 'wallpaper-16', label: 'Wallpaper 16', relativePath: 'wallpapers/wallpaper16.jpg', publicPath: '/wallpapers/wallpaper16.jpg' },
+ { id: 'wallpaper-17', label: 'Wallpaper 17', relativePath: 'wallpapers/wallpaper17.jpg', publicPath: '/wallpapers/wallpaper17.jpg' },
+ { id: 'wallpaper-18', label: 'Wallpaper 18', relativePath: 'wallpapers/wallpaper18.jpg', publicPath: '/wallpapers/wallpaper18.jpg' },
+ { id: 'cityscape', label: 'Cityscape', relativePath: 'wallpapers/cityscape.jpg', publicPath: '/wallpapers/cityscape.jpg' },
+ { id: 'farmvalley', label: 'Farm Valley', relativePath: 'wallpapers/farmvalley.jpg', publicPath: '/wallpapers/farmvalley.jpg' },
+ { id: 'levels', label: 'Levels', relativePath: 'wallpapers/levels.jpg', publicPath: '/wallpapers/levels.jpg' },
+ { id: 'mountaintrees', label: 'Mountain Trees', relativePath: 'wallpapers/mountaintrees.jpg', publicPath: '/wallpapers/mountaintrees.jpg' },
+ { id: 'luisdelrio', label: 'Luis Del Rio', relativePath: 'wallpapers/luisdelrio.jpg', publicPath: '/wallpapers/luisdelrio.jpg' },
+];
+
+export const WALLPAPER_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.publicPath);
+export const WALLPAPER_RELATIVE_PATHS = BUILT_IN_WALLPAPERS.map((wallpaper) => wallpaper.relativePath);
+export const DEFAULT_WALLPAPER_PATH = WALLPAPER_PATHS[0];
+export const DEFAULT_WALLPAPER_RELATIVE_PATH = WALLPAPER_RELATIVE_PATHS[0];
\ No newline at end of file
diff --git a/src/main.tsx b/src/main.tsx
index 9ec1b0ed..8f55ef09 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -3,6 +3,8 @@ import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
+document.documentElement.dataset.platform = /mac/i.test(navigator.platform) ? 'macos' : 'other'
+
ReactDOM.createRoot(document.getElementById('root')!).render(
diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts
index 0afbf583..2f9856f9 100644
--- a/src/vite-env.d.ts
+++ b/src/vite-env.d.ts
@@ -7,6 +7,26 @@ interface ProcessedDesktopSource {
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 {
@@ -16,6 +36,34 @@ interface Window {
openSourceSelector: () => Promise
selectSource: (source: any) => Promise
getSelectedSource: () => Promise
+ startNativeScreenRecording: (source: any, options?: {
+ capturesSystemAudio?: boolean
+ capturesMicrophone?: boolean
+ microphoneDeviceId?: 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
@@ -28,19 +76,72 @@ interface Window {
message?: string
error?: string
}>
+ readLocalFile: (filePath: string) => Promise<{
+ success: boolean
+ data?: Uint8Array
+ error?: string
+ }>
getAssetBasePath: () => Promise
setRecordingState: (recording: boolean) => Promise
+ getCursorTelemetry: (videoPath?: string) => Promise<{
+ success: boolean
+ samples: CursorTelemetryPoint[]
+ message?: string
+ error?: string
+ }>
+ getSystemCursorAssets: () => Promise<{
+ success: boolean
+ cursors: Record
+ 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
- cancelled?: boolean
+ canceled?: boolean
}>
- openVideoFilePicker: () => Promise<{ success: boolean; path?: string; cancelled?: 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
}
-}
\ No newline at end of file
+}
diff --git a/vite.config.ts b/vite.config.ts
index e6b6840c..a91e3e8b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -14,7 +14,9 @@ export default defineConfig({
entry: 'electron/main.ts',
vite: {
build: {
-
+ rollupOptions: {
+ external: ['ffmpeg-static', 'uiohook-napi'],
+ },
}
}
},