Merge remote-tracking branch 'origin/main' into codex/allow-png-background-upload

# Conflicts:
#	electron/ipc/handlers.ts
#	electron/main.ts
This commit is contained in:
Siddharth
2026-05-22 20:33:19 -07:00
185 changed files with 20471 additions and 2174 deletions
+170 -20
View File
@@ -1,5 +1,5 @@
import { Check, ChevronDown, Languages } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { BsPauseCircle, BsPlayCircle, BsRecordCircle } from "react-icons/bs";
import { FaRegStopCircle } from "react-icons/fa";
@@ -10,6 +10,7 @@ import {
MdMic,
MdMicOff,
MdMonitor,
MdMouse,
MdRestartAlt,
MdVideocam,
MdVideocamOff,
@@ -20,6 +21,7 @@ import {
import { RxDragHandleDots2 } from "react-icons/rx";
import { useI18n, useScopedT } from "@/contexts/I18nContext";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { nativeBridgeClient } from "@/native";
import { useAudioLevelMeter } from "../../hooks/useAudioLevelMeter";
import { useCameraDevices } from "../../hooks/useCameraDevices";
import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices";
@@ -42,6 +44,7 @@ const ICON_CONFIG = {
micOff: { icon: MdMicOff, size: ICON_SIZE },
webcamOn: { icon: MdVideocam, size: ICON_SIZE },
webcamOff: { icon: MdVideocamOff, size: ICON_SIZE },
cursor: { icon: MdMouse, size: ICON_SIZE },
pause: { icon: BsPauseCircle, size: ICON_SIZE },
resume: { icon: BsPlayCircle, size: ICON_SIZE },
stop: { icon: FaRegStopCircle, size: ICON_SIZE },
@@ -95,18 +98,23 @@ export function LaunchWindow() {
elapsedSeconds,
toggleRecording,
togglePaused,
canPauseRecording,
restartRecording,
cancelRecording,
microphoneEnabled,
setMicrophoneEnabled,
microphoneDeviceId,
setMicrophoneDeviceId,
setMicrophoneDeviceName,
systemAudioEnabled,
setSystemAudioEnabled,
webcamEnabled,
setWebcamEnabled,
webcamDeviceId,
setWebcamDeviceId,
setWebcamDeviceName,
cursorCaptureMode,
setCursorCaptureMode,
} = useScreenRecorder();
const showMicControls = microphoneEnabled && !recording;
@@ -120,6 +128,7 @@ export function LaunchWindow() {
const [isWebcamFocused, setIsWebcamFocused] = useState(false);
const webcamExpanded = isWebcamHovered || isWebcamFocused;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const [supportsCursorModeToggle, setSupportsCursorModeToggle] = useState(false);
const languageTriggerRef = useRef<HTMLButtonElement | null>(null);
const languageMenuPanelRef = useRef<HTMLDivElement | null>(null);
const [languageMenuStyle, setLanguageMenuStyle] = useState<{
@@ -148,14 +157,16 @@ export function LaunchWindow() {
const selectedMicLabel =
micDevices.find((d) => d.deviceId === (microphoneDeviceId || selectedMicId))?.label ||
t("audio.defaultMicrophone");
const selectedCameraDevice = cameraDevices.find(
(d) => d.deviceId === (webcamDeviceId || selectedCameraId),
);
const selectedCameraLabel = isCameraDevicesLoading
? t("webcam.searching")
: cameraDevicesError
? t("webcam.unavailable")
: cameraDevices.length === 0
? t("webcam.noneFound")
: cameraDevices.find((d) => d.deviceId === (webcamDeviceId || selectedCameraId))?.label ||
t("webcam.defaultCamera");
: selectedCameraDevice?.label || t("webcam.defaultCamera");
const { level } = useAudioLevelMeter({
enabled: showMicControls,
@@ -165,14 +176,36 @@ export function LaunchWindow() {
useEffect(() => {
if (selectedMicId && selectedMicId !== "default") {
setMicrophoneDeviceId(selectedMicId);
setMicrophoneDeviceName(micDevices.find((d) => d.deviceId === selectedMicId)?.label);
}
}, [selectedMicId, setMicrophoneDeviceId]);
}, [selectedMicId, micDevices, setMicrophoneDeviceId, setMicrophoneDeviceName]);
useEffect(() => {
if (selectedCameraId) {
setWebcamDeviceId(selectedCameraId);
setWebcamDeviceName(cameraDevices.find((d) => d.deviceId === selectedCameraId)?.label);
}
}, [selectedCameraId, setWebcamDeviceId]);
}, [selectedCameraId, cameraDevices, setWebcamDeviceId, setWebcamDeviceName]);
useEffect(() => {
let cancelled = false;
nativeBridgeClient.system
.getPlatform()
.then((platform) => {
if (!cancelled) {
setSupportsCursorModeToggle(platform === "win32" || platform === "darwin");
}
})
.catch(() => {
if (!cancelled) {
setSupportsCursorModeToggle(false);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!import.meta.env.DEV) {
@@ -249,15 +282,29 @@ export function LaunchWindow() {
return () => cancelAnimationFrame(id);
}, [isLanguageMenuOpen]);
const hudMouseEventsEnabledRef = useRef<boolean | undefined>(undefined);
const setHudMouseEventsEnabled = useCallback((enabled: boolean) => {
if (hudMouseEventsEnabledRef.current === enabled) {
return;
}
hudMouseEventsEnabledRef.current = enabled;
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!enabled);
}, []);
useEffect(() => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true);
setHudMouseEventsEnabled(false);
return () => {
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(false);
};
}, []);
}, [setHudMouseEventsEnabled]);
useEffect(() => {
setHudMouseEventsEnabled(isLanguageMenuOpen);
}, [isLanguageMenuOpen, setHudMouseEventsEnabled]);
const [selectedSource, setSelectedSource] = useState("Screen");
const [hasSelectedSource, setHasSelectedSource] = useState(false);
const [, setRecordPointerDownCount] = useState(0);
useEffect(() => {
const checkSelectedSource = async () => {
@@ -293,13 +340,17 @@ export function LaunchWindow() {
}
if (result.success && result.path) {
await window.electronAPI.setCurrentVideoPath(result.path);
const setVideoPathResult = await nativeBridgeClient.project.setCurrentVideoPath(result.path);
if (!setVideoPathResult.success) {
console.error("Failed to set current video path:", setVideoPathResult);
return;
}
await window.electronAPI.switchToEditor();
}
};
const openProjectFile = async () => {
const result = await window.electronAPI.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled || !result.success) return;
await window.electronAPI.switchToEditor();
};
@@ -320,6 +371,29 @@ export function LaunchWindow() {
setMicrophoneEnabled(!microphoneEnabled);
}
};
const dragLastPositionRef = useRef<{ x: number; y: number } | null>(null);
const handleHudDragPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
setHudMouseEventsEnabled(true);
event.currentTarget.setPointerCapture(event.pointerId);
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
};
const handleHudDragPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const lastPosition = dragLastPositionRef.current;
if (!lastPosition) return;
const deltaX = event.screenX - lastPosition.x;
const deltaY = event.screenY - lastPosition.y;
dragLastPositionRef.current = { x: event.screenX, y: event.screenY };
window.electronAPI?.moveHudOverlayBy?.(deltaX, deltaY);
};
const handleHudDragPointerEnd = (event: React.PointerEvent<HTMLDivElement>) => {
dragLastPositionRef.current = null;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setHudMouseEventsEnabled(false);
};
return (
// Root fills the HUD window only. Avoid w-screen/h-screen (100vw/100vh):
@@ -330,13 +404,19 @@ export function LaunchWindow() {
className={`h-full w-full min-w-0 max-w-full overflow-x-hidden overflow-y-hidden bg-transparent ${styles.electronDrag}`}
onPointerMove={(event) => {
const target = event.target as HTMLElement | null;
const shouldCapture = Boolean(target?.closest("[data-hud-interactive='true']"));
window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(!shouldCapture);
const shouldCapture =
isLanguageMenuOpen || Boolean(target?.closest("[data-hud-interactive='true']"));
setHudMouseEventsEnabled(shouldCapture);
}}
onPointerLeave={() => {
if (!isLanguageMenuOpen) {
setHudMouseEventsEnabled(false);
}
}}
onPointerLeave={() => window.electronAPI?.setHudOverlayIgnoreMouseEvents?.(true)}
>
{systemLocaleSuggestion && (
<div
data-hud-interactive="true"
className={`fixed top-8 left-1/2 z-30 w-[calc(100vw-1rem)] max-w-[520px] -translate-x-1/2 rounded-xl border border-white/15 bg-[rgba(20,20,28,0.95)] p-3 shadow-2xl backdrop-blur-xl text-white animate-in fade-in-0 zoom-in-95 duration-200 ${styles.electronNoDrag}`}
>
<div className="text-[13px] font-semibold text-white">
@@ -396,8 +476,10 @@ export function LaunchWindow() {
<select
value={microphoneDeviceId || selectedMicId}
onChange={(e) => {
const selectedDevice = micDevices.find((d) => d.deviceId === e.target.value);
setSelectedMicId(e.target.value);
setMicrophoneDeviceId(e.target.value);
setMicrophoneDeviceName(selectedDevice?.label);
}}
className={`w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer ${!micExpanded ? "sr-only" : ""}`}
>
@@ -455,8 +537,12 @@ export function LaunchWindow() {
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
const device = cameraDevices.find(
(item) => item.deviceId === e.target.value,
);
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
setWebcamDeviceName(device?.label);
}}
className="w-full appearance-none bg-white/5 text-white text-[11px] rounded-lg pl-2 pr-6 py-1 border border-white/10 outline-none hover:bg-white/10 transition-colors cursor-pointer"
>
@@ -480,8 +566,10 @@ export function LaunchWindow() {
<select
value={webcamDeviceId || selectedCameraId}
onChange={(e) => {
const device = cameraDevices.find((item) => item.deviceId === e.target.value);
setSelectedCameraId(e.target.value);
setWebcamDeviceId(e.target.value);
setWebcamDeviceName(device?.label);
}}
className="sr-only"
>
@@ -502,9 +590,23 @@ export function LaunchWindow() {
<div
data-hud-interactive="true"
className={`fixed bottom-5 left-1/2 -translate-x-1/2 flex items-center gap-1.5 rounded-2xl border border-white/[0.10] bg-[#07080a]/90 px-2 py-1.5 shadow-[0_20px_60px_rgba(0,0,0,0.42),inset_0_1px_0_rgba(255,255,255,0.06)] backdrop-blur-2xl backdrop-saturate-[140%]`}
onPointerEnter={() => setHudMouseEventsEnabled(true)}
onPointerDown={() => setHudMouseEventsEnabled(true)}
onMouseEnter={() => setHudMouseEventsEnabled(true)}
onMouseLeave={() => {
if (!isLanguageMenuOpen) {
setHudMouseEventsEnabled(false);
}
}}
>
{/* Drag handle */}
<div className={`flex items-center px-1 ${styles.electronDrag}`}>
<div
className={`flex h-8 w-7 cursor-grab items-center justify-center active:cursor-grabbing ${styles.electronNoDrag}`}
onPointerDown={handleHudDragPointerDown}
onPointerMove={handleHudDragPointerMove}
onPointerUp={handleHudDragPointerEnd}
onPointerCancel={handleHudDragPointerEnd}
>
{getIcon("drag", "text-white/30")}
</div>
@@ -524,6 +626,7 @@ export function LaunchWindow() {
{/* Audio controls group */}
<div className={`${hudGroupClasses} ${styles.electronNoDrag}`}>
<button
data-testid="launch-system-audio-button"
className={`${hudIconBtnClasses} ${systemAudioEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={() => !recording && setSystemAudioEnabled(!systemAudioEnabled)}
disabled={recording}
@@ -536,16 +639,21 @@ export function LaunchWindow() {
: getIcon("volumeOff", "text-white/40")}
</button>
<button
data-testid="launch-microphone-button"
className={`${hudIconBtnClasses} ${microphoneEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={toggleMicrophone}
disabled={recording}
title={microphoneEnabled ? t("audio.disableMicrophone") : t("audio.enableMicrophone")}
onPointerDown={() => {
setRecordPointerDownCount((count) => count + 1);
}}
>
{microphoneEnabled
? getIcon("micOn", "text-green-400")
: getIcon("micOff", "text-white/40")}
</button>
<button
data-testid="launch-webcam-button"
className={`${hudIconBtnClasses} ${webcamEnabled ? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]" : ""}`}
onClick={async () => {
await setWebcamEnabled(!webcamEnabled);
@@ -557,10 +665,38 @@ export function LaunchWindow() {
? getIcon("webcamOn", "text-green-400")
: getIcon("webcamOff", "text-white/40")}
</button>
{supportsCursorModeToggle && (
<button
data-testid="launch-cursor-mode-button"
className={`${hudIconBtnClasses} ${
cursorCaptureMode === "editable-overlay"
? "drop-shadow-[0_0_4px_rgba(74,222,128,0.4)]"
: ""
}`}
onClick={() =>
!recording &&
setCursorCaptureMode(
cursorCaptureMode === "editable-overlay" ? "system" : "editable-overlay",
)
}
disabled={recording}
title={
cursorCaptureMode === "editable-overlay"
? t("cursor.useSystemCursor")
: t("cursor.useEditableCursor")
}
>
{getIcon(
"cursor",
cursorCaptureMode === "editable-overlay" ? "text-green-400" : "text-white/40",
)}
</button>
)}
</div>
{/* Record/Stop group */}
<button
data-testid="launch-record-button"
className={`flex items-center justify-center rounded-full p-2 transition-[min-width,background-color] duration-150 ${recording ? "min-w-[78px]" : "min-w-[36px]"} ${styles.electronNoDrag} ${
recording
? paused
@@ -588,13 +724,18 @@ export function LaunchWindow() {
{recording && (
<div className={`flex items-center gap-0.5 ${styles.electronNoDrag}`}>
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(paused ? "resume" : "pause", paused ? "text-amber-400" : "text-white/60")}
</button>
</Tooltip>
{canPauseRecording && (
<Tooltip
content={paused ? t("tooltips.resumeRecording") : t("tooltips.pauseRecording")}
>
<button className={hudAuxIconBtnClasses} onClick={togglePaused}>
{getIcon(
paused ? "resume" : "pause",
paused ? "text-amber-400" : "text-white/60",
)}
</button>
</Tooltip>
)}
<Tooltip content={t("tooltips.restartRecording")}>
<button className={hudAuxIconBtnClasses} onClick={restartRecording}>
{getIcon("restart", "text-white/60")}
@@ -613,6 +754,7 @@ export function LaunchWindow() {
{/* Open video file */}
<Tooltip content={t("tooltips.openVideoFile")}>
<button
data-testid="launch-open-video-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openVideoFile}
>
@@ -623,6 +765,7 @@ export function LaunchWindow() {
{/* Open project */}
<Tooltip content={t("tooltips.openProject")}>
<button
data-testid="launch-open-project-button"
className={`${hudIconBtnClasses} ${styles.electronNoDrag}`}
onClick={openProjectFile}
>
@@ -655,6 +798,7 @@ export function LaunchWindow() {
? createPortal(
<div
ref={languageMenuPanelRef}
data-hud-interactive="true"
role="menu"
className={`${styles.languageMenuPanel} ${styles.languageMenuScroll} ${styles.electronNoDrag}`}
style={
@@ -667,6 +811,12 @@ export function LaunchWindow() {
} as React.CSSProperties
}
onPointerDown={(event) => event.stopPropagation()}
onPointerEnter={() => setHudMouseEventsEnabled(true)}
onPointerMove={() => setHudMouseEventsEnabled(true)}
onWheel={(event) => {
setHudMouseEventsEnabled(true);
event.stopPropagation();
}}
>
{availableLocales.map((loc) => (
<button
+2
View File
@@ -145,6 +145,7 @@ export function SourceSelector() {
</div>
<div className="flex justify-center gap-2 border-t border-white/[0.06] p-3">
<Button
data-testid="source-selector-cancel-button"
variant="ghost"
onClick={() => window.close()}
className="h-8 rounded-lg px-5 text-[11px] text-zinc-400 transition-transform duration-150 hover:bg-white/5 hover:text-white active:scale-95"
@@ -152,6 +153,7 @@ export function SourceSelector() {
{tc("actions.cancel")}
</Button>
<Button
data-testid="source-selector-share-button"
onClick={handleShare}
disabled={!selectedSource}
className="h-8 rounded-lg bg-[#34B27B] px-5 text-[11px] font-semibold text-white transition-transform duration-150 hover:bg-[#34B27B]/85 active:scale-95 disabled:bg-zinc-700 disabled:opacity-30"
+3 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio } from "@/utils/aspectRatioUtils";
import { DEFAULT_SOURCE_DIMENSIONS } from "./editorDefaults";
interface CropRegion {
x: number; // 0-1 normalized
@@ -32,8 +33,8 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont
const ctx = canvas.getContext("2d", { alpha: false });
if (!ctx) return;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
canvas.width = videoElement.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
canvas.height = videoElement.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const draw = () => {
if (videoElement.readyState >= 2) {
+3 -1
View File
@@ -161,7 +161,9 @@ export function ExportDialog({
<div className="p-1 bg-red-500/20 rounded-full">
<X className="w-3 h-3 text-red-400" />
</div>
<p className="text-sm text-red-400 leading-relaxed">{error}</p>
<p className="whitespace-pre-wrap break-words text-sm text-red-400 leading-relaxed">
{error}
</p>
</div>
</div>
)}
+279 -308
View File
@@ -1,7 +1,6 @@
import * as SliderPrimitive from "@radix-ui/react-slider";
import {
Bug,
ChevronDown,
Crop,
Download,
FileDown,
@@ -28,7 +27,6 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -42,7 +40,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useScopedT } from "@/contexts/I18nContext";
import { WEBCAM_LAYOUT_PRESETS } from "@/lib/compositeLayout";
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { GIF_FRAME_RATES, GIF_SIZE_PRESETS } from "@/lib/exporter";
import {
calculateEffectiveSourceDimensions,
GIF_FRAME_RATES,
GIF_SIZE_PRESETS,
} from "@/lib/exporter";
import { cn } from "@/lib/utils";
import { resolveImageWallpaperUrl, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
@@ -52,6 +54,15 @@ import { AnnotationSettingsPanel } from "./AnnotationSettingsPanel";
import { BlurSettingsPanel } from "./BlurSettingsPanel";
import { BACKGROUND_IMAGE_ACCEPT, isSupportedBackgroundImageType } from "./backgroundImageUpload";
import { CropControl } from "./CropControl";
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
import {
DEFAULT_CURSOR_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_SOURCE_DIMENSIONS,
DEFAULT_WEBCAM_SETTINGS,
} from "./editorDefaults";
import { KeyboardShortcutsHelp } from "./KeyboardShortcutsHelp";
import type {
AnnotationRegion,
@@ -69,8 +80,6 @@ import type {
ZoomFocusMode,
} from "./types";
import {
DEFAULT_WEBCAM_SIZE_PRESET,
MAX_PLAYBACK_SPEED,
MAX_ZOOM_SCALE,
MIN_ZOOM_SCALE,
ROTATION_3D_PRESET_ORDER,
@@ -89,37 +98,38 @@ function CustomSpeedInput({
onError: () => void;
}) {
const isPreset = SPEED_OPTIONS.some((o) => o.speed === value);
const [draft, setDraft] = useState(isPreset ? "" : String(Math.round(value)));
const [draft, setDraft] = useState(isPreset ? "" : String(value));
const [isFocused, setIsFocused] = useState(false);
const prevValue = useRef(value);
if (!isFocused && prevValue.current !== value) {
prevValue.current = value;
setDraft(isPreset ? "" : String(Math.round(value)));
setDraft(isPreset ? "" : String(value));
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const digits = e.target.value.replace(/\D/g, "");
if (digits === "") {
setDraft("");
return;
}
const num = Number(digits);
if (num > MAX_PLAYBACK_SPEED) {
const result = parseCustomPlaybackSpeedInput(e.target.value);
if (result.status === "too-fast") {
onError();
return;
}
setDraft(digits);
if (num >= 1) onChange(num);
setDraft(result.draft);
if (result.status === "valid") {
onChange(result.speed);
}
},
[onChange, onError],
);
const handleBlur = useCallback(() => {
setIsFocused(false);
if (!draft || Number(draft) < 1) {
setDraft(isPreset ? "" : String(Math.round(value)));
const result = parseCustomPlaybackSpeedInput(draft);
if (result.status === "valid") {
setDraft(String(result.speed));
} else {
setDraft(isPreset ? "" : String(value));
}
}, [draft, isPreset, value]);
@@ -127,8 +137,8 @@ function CustomSpeedInput({
<div className="flex items-center gap-1">
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
inputMode="decimal"
pattern="[0-9]*[.]?[0-9]*"
placeholder="--"
value={draft}
onFocus={() => setIsFocused(true)}
@@ -222,12 +232,6 @@ const GRADIENTS = [
];
interface SettingsPanelProps {
cursorHighlight?: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
onCursorHighlightChange?: (
next: import("./videoPlayback/cursorHighlight").CursorHighlightConfig,
) => void;
// macOS only — gates the "Only on clicks" toggle (needs uiohook).
cursorHighlightSupportsClicks?: boolean;
selected: string;
onWallpaperChange: (path: string) => void;
selectedZoomDepth?: ZoomDepth | null;
@@ -310,6 +314,20 @@ interface SettingsPanelProps {
onWebcamSizePresetChange?: (size: WebcamSizePreset) => void;
onWebcamSizePresetCommit?: () => void;
onSaveDiagnostic?: () => Promise<void>;
showCursor?: boolean;
onShowCursorChange?: (show: boolean) => void;
cursorSize?: number;
onCursorSizeChange?: (size: number) => void;
cursorSmoothing?: number;
onCursorSmoothingChange?: (smoothing: number) => void;
cursorMotionBlur?: number;
onCursorMotionBlurChange?: (blur: number) => void;
cursorClickBounce?: number;
onCursorClickBounceChange?: (bounce: number) => void;
cursorClipToBounds?: boolean;
onCursorClipToBoundsChange?: (clip: boolean) => void;
hasCursorData?: boolean;
showCursorSettings?: boolean;
}
export default SettingsPanel;
@@ -325,10 +343,24 @@ const ZOOM_DEPTH_OPTIONS: Array<{ depth: ZoomDepth; label: string }> = [
type SettingsPanelMode = "background" | "effects" | "layout" | "cursor" | "export";
const MP4_EXPORT_SHORT_SIDES = {
medium: 720,
good: 1080,
} as const;
function formatSourceDimensions(videoElement?: HTMLVideoElement | null, cropRegion?: CropRegion) {
const width = videoElement?.videoWidth ?? 0;
const height = videoElement?.videoHeight ?? 0;
if (width <= 0 || height <= 0) {
return null;
}
const dimensions = calculateEffectiveSourceDimensions(width, height, cropRegion);
return { ...dimensions, shortSide: Math.min(dimensions.width, dimensions.height) };
}
export function SettingsPanel({
cursorHighlight,
onCursorHighlightChange,
cursorHighlightSupportsClicks = false,
selected,
onWallpaperChange,
selectedZoomDepth,
@@ -359,24 +391,24 @@ export function SettingsPanel({
borderRadius = 0,
onBorderRadiusChange,
onBorderRadiusCommit,
padding = 50,
padding = DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
onPaddingChange,
onPaddingCommit,
cropRegion,
onCropChange,
aspectRatio,
videoElement,
exportQuality = "good",
exportQuality = DEFAULT_EXPORT_SETTINGS.quality,
onExportQualityChange,
exportFormat = "mp4",
exportFormat = DEFAULT_EXPORT_SETTINGS.format,
onExportFormatChange,
gifFrameRate = 15,
gifFrameRate = DEFAULT_GIF_SETTINGS.frameRate,
onGifFrameRateChange,
gifLoop = true,
gifLoop = DEFAULT_GIF_SETTINGS.loop,
onGifLoopChange,
gifSizePreset = "medium",
gifSizePreset = DEFAULT_GIF_SETTINGS.sizePreset,
onGifSizePresetChange,
gifOutputDimensions = { width: 1280, height: 720 },
gifOutputDimensions = DEFAULT_GIF_SETTINGS.outputDimensions,
onExport,
unsavedExport,
onSaveUnsavedExport,
@@ -398,17 +430,32 @@ export function SettingsPanel({
onSpeedChange,
onSpeedDelete,
hasWebcam = false,
webcamLayoutPreset = "picture-in-picture",
webcamLayoutPreset = DEFAULT_WEBCAM_SETTINGS.layoutPreset,
onWebcamLayoutPresetChange,
webcamMaskShape = "rectangle",
webcamMaskShape = DEFAULT_WEBCAM_SETTINGS.maskShape,
onWebcamMaskShapeChange,
webcamSizePreset = DEFAULT_WEBCAM_SIZE_PRESET,
webcamSizePreset = DEFAULT_WEBCAM_SETTINGS.sizePreset,
onWebcamSizePresetChange,
onWebcamSizePresetCommit,
onSaveDiagnostic,
showCursor = DEFAULT_CURSOR_SETTINGS.show,
onShowCursorChange,
cursorSize = DEFAULT_CURSOR_SETTINGS.size,
onCursorSizeChange,
cursorSmoothing = DEFAULT_CURSOR_SETTINGS.smoothing,
onCursorSmoothingChange,
cursorMotionBlur = DEFAULT_CURSOR_SETTINGS.motionBlur,
onCursorMotionBlurChange,
cursorClickBounce = DEFAULT_CURSOR_SETTINGS.clickBounce,
onCursorClickBounceChange,
cursorClipToBounds = DEFAULT_CURSOR_SETTINGS.clipToBounds,
onCursorClipToBoundsChange,
hasCursorData = false,
showCursorSettings = true,
}: SettingsPanelProps) {
const t = useScopedT("settings");
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
const sourceDimensions = formatSourceDimensions(videoElement, cropRegion);
// Resolved URLs are for DOM rendering only (backgroundImage). The canonical
// `/wallpapers/wallpaperN.jpg` form in WALLPAPER_PATHS is what gets persisted
// on click — never the machine-specific file:// URL.
@@ -436,14 +483,12 @@ export function SettingsPanel({
const [selectedColor, setSelectedColor] = useState("#ADADAD");
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropModal, setShowCropModal] = useState(false);
const cropSnapshotRef = useRef<CropRegion | null>(null);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState("");
const isPortraitCanvas = isPortraitAspectRatio(aspectRatio);
const videoWidth = videoElement?.videoWidth || 1920;
const videoHeight = videoElement?.videoHeight || 1080;
const videoWidth = videoElement?.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
const videoHeight = videoElement?.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const handleCropNumericChange = useCallback(
(field: "x" | "y" | "width" | "height", pixelValue: number) => {
@@ -538,10 +583,13 @@ export function SettingsPanel({
},
[cropRegion, videoWidth, videoHeight],
);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const handleCropToggle = () => setShowCropDropdown((open) => !open);
const zoomEnabled = Boolean(selectedZoomDepth);
const trimEnabled = Boolean(selectedTrimId);
const hasTimelineSelection = Boolean(selectedZoomId || selectedTrimId || selectedSpeedId);
const hasCursorPanel = showCursorSettings && hasCursorData;
const panelModes: Array<{
id: SettingsPanelMode;
label: string;
@@ -551,7 +599,15 @@ export function SettingsPanel({
{ id: "background", label: t("background.title"), icon: Palette },
{ id: "effects", label: t("effects.title"), icon: SlidersHorizontal },
{ id: "layout", label: t("layout.title"), icon: LayoutPanelTop, disabled: !hasWebcam },
{ id: "cursor", label: t("effects.cursorHighlight.title"), icon: MousePointerClick },
...(hasCursorPanel
? [
{
id: "cursor" as const,
label: t("effects.title"),
icon: MousePointerClick,
},
]
: []),
];
const exportPanelMode = {
id: "export" as const,
@@ -624,20 +680,6 @@ export function SettingsPanel({
}
};
const handleCropToggle = () => {
if (!showCropModal && cropRegion) {
cropSnapshotRef.current = { ...cropRegion };
}
setShowCropModal(!showCropModal);
};
const handleCropCancel = () => {
if (cropSnapshotRef.current && onCropChange) {
onCropChange(cropSnapshotRef.current);
}
setShowCropModal(false);
};
// Find selected annotation
const selectedAnnotation = selectedAnnotationId
? annotationRegions.find((a) => a.id === selectedAnnotationId)
@@ -657,7 +699,7 @@ export function SettingsPanel({
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"
>
<Bug className="w-3 h-3 text-[#34B27B]" />
{t("links.reportBug")}
{t("support.reportBug")}
</button>
{onSaveDiagnostic && (
<button
@@ -666,7 +708,7 @@ export function SettingsPanel({
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"
>
<FileDown className="w-3 h-3 text-slate-400" />
Save Diagnostics
{t("support.saveDiagnostics")}
</button>
)}
<button
@@ -677,7 +719,7 @@ export function SettingsPanel({
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"
>
<Star className="w-3 h-3 text-yellow-400" />
{t("links.starOnGithub")}
{t("support.starOnGithub")}
</button>
</div>
);
@@ -773,6 +815,7 @@ export function SettingsPanel({
<Crop className="h-4 w-4" />
</button>
<button
data-testid={getTestId("export-panel-button")}
type="button"
title={exportPanelMode.label}
onClick={() => setActivePanelMode(exportPanelMode.id)}
@@ -1264,11 +1307,7 @@ export function SettingsPanel({
) : (
<SlidersHorizontal className="w-4 h-4 text-[#34B27B]" />
)}
<span className="text-xs font-medium">
{activePanelMode === "cursor"
? t("effects.cursorHighlight.title")
: t("effects.title")}
</span>
<span className="text-xs font-medium">{t("effects.title")}</span>
</div>
</AccordionTrigger>
<AccordionContent className="pb-3">
@@ -1373,218 +1412,107 @@ export function SettingsPanel({
</>
)}
{activePanelMode === "cursor" && cursorHighlight && onCursorHighlightChange && (
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-2">
{activePanelMode === "cursor" && showCursorSettings && hasCursorData && (
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-3">
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">
{t("effects.cursorHighlight.title")}
{t("cursor.show")}
</div>
<button
type="button"
onClick={() =>
onCursorHighlightChange({
...cursorHighlight,
enabled: !cursorHighlight.enabled,
})
}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.enabled
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.enabled ? t("effects.on") : t("effects.off")}
</button>
</div>
<div
className={`grid grid-cols-2 gap-1 ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
{(["dot", "ring"] as const).map((style) => (
<button
key={style}
type="button"
onClick={() => onCursorHighlightChange({ ...cursorHighlight, style })}
className={`text-[10px] px-2 py-1 rounded border capitalize transition-colors ${
cursorHighlight.style === style
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-300 hover:border-white/20"
}`}
>
{t(`effects.cursorHighlight.${style}`)}
</button>
))}
</div>
<div
className={
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.size")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorHighlight.sizePx}px
</span>
</div>
<Slider
value={[cursorHighlight.sizePx]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
sizePx: values[0],
})
}
min={10}
max={36}
step={1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
<Switch
checked={showCursor}
onCheckedChange={onShowCursorChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
/>
</div>
{cursorHighlightSupportsClicks && (
<div
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
>
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.onlyOnClicks")}
</div>
<button
type="button"
onClick={async () => {
const turningOn = !cursorHighlight.onlyOnClicks;
if (turningOn) {
try {
const result =
await window.electronAPI?.requestAccessibilityAccess?.();
if (!result?.granted) {
toast.message(
t("effects.cursorHighlight.accessibilityPermissionTitle"),
{
description: t(
"effects.cursorHighlight.accessibilityPermissionDescription",
),
},
);
return;
}
} catch (err) {
console.warn("Accessibility request failed:", err);
}
}
onCursorHighlightChange({
...cursorHighlight,
onlyOnClicks: turningOn,
});
}}
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
cursorHighlight.onlyOnClicks
? "bg-[#34B27B]/20 border-[#34B27B]/50 text-[#34B27B]"
: "bg-white/5 border-white/10 text-slate-400"
}`}
>
{cursorHighlight.onlyOnClicks ? t("effects.on") : t("effects.off")}
</button>
</div>
)}
<div
className={
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
}
>
<div className="text-[10px] text-slate-400 mb-1">
{t("effects.cursorHighlight.color")}
</div>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className="w-full h-8 justify-start gap-2 bg-white/5 border-white/10 hover:bg-white/10 px-2"
>
<div
className="w-4 h-4 rounded-full border border-white/20"
style={{ backgroundColor: cursorHighlight.color }}
/>
<span className="text-[10px] text-slate-300 truncate flex-1 text-left font-mono">
{cursorHighlight.color}
</span>
<ChevronDown className="h-3 w-3 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
className="w-[260px] p-3 bg-[#1a1a1c] border border-white/10 rounded-xl shadow-xl"
>
<ColorPicker
selectedColor={cursorHighlight.color}
colorPalette={colorPalette}
translations={{
colorWheel: t("background.colorWheel"),
colorPalette: t("background.colorPalette"),
}}
onUpdateColor={(color) =>
onCursorHighlightChange({
...cursorHighlight,
color,
})
}
{showCursor && (
<>
<div className="flex items-center justify-between">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.clipToBounds")}
</div>
<Switch
checked={cursorClipToBounds}
onCheckedChange={onCursorClipToBoundsChange}
className="data-[state=checked]:bg-[#34B27B] scale-90"
aria-label={t("cursor.clipToBounds")}
/>
</PopoverContent>
</Popover>
</div>
<div
className={
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.offsetX")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetXNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetXNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetXNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div
className={
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
}
>
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] text-slate-400">
{t("effects.cursorHighlight.offsetY")}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.size")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorSize.toFixed(1)}
</span>
</div>
<Slider
value={[cursorSize]}
onValueChange={(values) => onCursorSizeChange?.(values[0])}
min={0.5}
max={10}
step={0.1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.smoothing")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorSmoothing * 100)}%
</span>
</div>
<Slider
value={[cursorSmoothing]}
onValueChange={(values) => onCursorSmoothingChange?.(values[0])}
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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.motionBlur")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{Math.round(cursorMotionBlur * 100)}%
</span>
</div>
<Slider
value={[cursorMotionBlur]}
onValueChange={(values) => onCursorMotionBlurChange?.(values[0])}
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"
/>
</div>
<div className="p-2 rounded-lg bg-white/5 border border-white/5">
<div className="flex items-center justify-between mb-1">
<div className="text-[10px] font-medium text-slate-300">
{t("cursor.clickBounce")}
</div>
<span className="text-[10px] text-slate-500 font-mono">
{cursorClickBounce.toFixed(1)}
</span>
</div>
<Slider
value={[cursorClickBounce]}
onValueChange={(values) => onCursorClickBounceChange?.(values[0])}
min={0}
max={5}
step={0.1}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
</div>
<span className="text-[10px] text-slate-500 font-mono">
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
</span>
</div>
<Slider
value={[cursorHighlight.offsetYNorm]}
onValueChange={(values) =>
onCursorHighlightChange({
...cursorHighlight,
offsetYNorm: values[0],
})
}
min={-0.25}
max={0.25}
step={0.005}
className="w-full [&_[role=slider]]:bg-[#34B27B] [&_[role=slider]]:border-[#34B27B] [&_[role=slider]]:h-3 [&_[role=slider]]:w-3"
/>
</div>
</>
)}
</div>
)}
</AccordionContent>
@@ -1744,11 +1672,11 @@ export function SettingsPanel({
</div>
</div>
{showCropModal && cropRegion && onCropChange && (
{showCropDropdown && cropRegion && onCropChange && (
<>
<div
className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 animate-in fade-in duration-200"
onClick={handleCropCancel}
onClick={() => setShowCropDropdown(false)}
/>
<div className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-[60] bg-[#09090b] rounded-2xl shadow-2xl border border-white/10 p-8 w-[90vw] max-w-5xl max-h-[90vh] overflow-auto animate-in zoom-in-95 duration-200">
<div className="flex items-center justify-between mb-6">
@@ -1759,7 +1687,7 @@ export function SettingsPanel({
<Button
variant="ghost"
size="icon"
onClick={handleCropCancel}
onClick={() => setShowCropDropdown(false)}
className="hover:bg-white/10 text-slate-400 hover:text-white"
>
<X className="w-5 h-5" />
@@ -1855,7 +1783,7 @@ export function SettingsPanel({
<div className="flex justify-end">
<Button
onClick={() => setShowCropModal(false)}
onClick={() => setShowCropDropdown(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
@@ -1872,6 +1800,7 @@ export function SettingsPanel({
<>
<div className="flex items-center gap-2 mb-3">
<button
data-testid={getTestId("mp4-format-button")}
onClick={() => onExportFormatChange?.("mp4")}
className={cn(
"flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg border transition-all text-xs font-medium",
@@ -1899,40 +1828,82 @@ export function SettingsPanel({
</div>
{exportFormat === "mp4" && (
<div className="mb-3 bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-7 rounded-lg">
<button
onClick={() => onExportQualityChange?.("medium")}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === "medium"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
{t("exportQuality.low")}
</button>
<button
onClick={() => onExportQualityChange?.("good")}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === "good"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
{t("exportQuality.medium")}
</button>
<button
onClick={() => onExportQualityChange?.("source")}
className={cn(
"rounded-md transition-all text-[10px] font-medium",
exportQuality === "source"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
{t("exportQuality.high")}
</button>
<div className="mb-3 space-y-1.5">
{sourceDimensions && (
<div className="flex items-center justify-between px-0.5 text-[10px] leading-none text-slate-500">
<span>{t("exportQuality.title")}</span>
<span>
Source {sourceDimensions.width}x{sourceDimensions.height}
</span>
</div>
)}
<div className="bg-white/5 border border-white/5 p-0.5 w-full grid grid-cols-3 h-9 rounded-lg">
<button
onClick={() => onExportQualityChange?.("medium")}
className={cn(
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
exportQuality === "medium"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
<span>{t("exportQuality.low")}</span>
{sourceDimensions &&
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.medium && (
<span
className={cn(
"text-[8px] font-medium",
exportQuality === "medium" ? "text-black/55" : "text-amber-300/80",
)}
>
Upscale
</span>
)}
</button>
<button
onClick={() => onExportQualityChange?.("good")}
className={cn(
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
exportQuality === "good"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
<span>{t("exportQuality.medium")}</span>
{sourceDimensions &&
sourceDimensions.shortSide < MP4_EXPORT_SHORT_SIDES.good && (
<span
className={cn(
"text-[8px] font-medium",
exportQuality === "good" ? "text-black/55" : "text-amber-300/80",
)}
>
Upscale
</span>
)}
</button>
<button
onClick={() => onExportQualityChange?.("source")}
className={cn(
"rounded-md transition-all text-[10px] font-medium flex flex-col items-center justify-center leading-none gap-0.5",
exportQuality === "source"
? "bg-white text-black"
: "text-slate-400 hover:text-slate-200",
)}
>
<span>{t("exportQuality.high")}</span>
{sourceDimensions && (
<span
className={cn(
"text-[8px] font-medium",
exportQuality === "source" ? "text-black/55" : "text-slate-500",
)}
>
{sourceDimensions.shortSide}p
</span>
)}
</button>
</div>
</div>
)}
+340 -190
View File
@@ -16,7 +16,10 @@ import { useShortcuts } from "@/contexts/ShortcutsContext";
import { INITIAL_EDITOR_STATE, useEditorHistory } from "@/hooks/useEditorHistory";
import { type Locale } from "@/i18n/config";
import { getAvailableLocales, getLocaleName } from "@/i18n/loader";
import { hasNativeCursorRecordingData } from "@/lib/cursor/nativeCursor";
import {
calculateEffectiveSourceDimensions,
calculateMp4ExportSettings,
calculateOutputDimensions,
type ExportFormat,
type ExportProgress,
@@ -29,7 +32,7 @@ import {
VideoExporter,
} from "@/lib/exporter";
import { computeFrameStepTime } from "@/lib/frameStep";
import type { ProjectMedia } from "@/lib/recordingSession";
import type { CursorCaptureMode, ProjectMedia } from "@/lib/recordingSession";
import { matchesShortcut } from "@/lib/shortcuts";
import {
getExportFolder,
@@ -38,12 +41,20 @@ import {
saveUserPreferences,
} from "@/lib/userPreferences";
import { BackgroundLoadError } from "@/lib/wallpaper";
import { nativeBridgeClient, useCursorRecordingData, useCursorTelemetry } from "@/native";
import type { NativePlatform } from "@/native/contracts";
import {
getAspectRatioValue,
getNativeAspectRatioValue,
isPortraitAspectRatio,
} from "@/utils/aspectRatioUtils";
import { ExportDialog } from "./ExportDialog";
import {
DEFAULT_CURSOR_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_SOURCE_DIMENSIONS,
} from "./editorDefaults";
import PlaybackControls from "./PlaybackControls";
import {
createProjectData,
@@ -61,7 +72,6 @@ import TimelineEditor from "./timeline/TimelineEditor";
import {
type AnnotationRegion,
type BlurData,
type CursorTelemetryPoint,
clampFocusToDepth,
DEFAULT_ANNOTATION_POSITION,
DEFAULT_ANNOTATION_SIZE,
@@ -84,6 +94,62 @@ import {
import { UnsavedChangesDialog } from "./UnsavedChangesDialog";
import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback";
function isClickInteractionType(interactionType: string | null | undefined) {
return (
interactionType === "click" ||
interactionType === "double-click" ||
interactionType === "right-click" ||
interactionType === "middle-click"
);
}
interface ExportDiagnostics {
formatLabel: "GIF" | "Video";
reason?: string;
sourcePath?: string | null;
width?: number;
height?: number;
frameRate?: number;
codec?: string;
bitrate?: number;
}
function getFileNameForDiagnostics(filePath?: string | null) {
if (!filePath) return "unknown";
try {
const url = new URL(filePath);
if (url.protocol === "file:") {
return decodeURIComponent(url.pathname).split(/[\\/]/).pop() || filePath;
}
} catch {
// Treat non-URL values as filesystem paths.
}
return filePath.split(/[\\/]/).pop() || filePath;
}
function buildExportDiagnosticMessage(diagnostics: ExportDiagnostics) {
const details = [
diagnostics.reason ? `Reason: ${diagnostics.reason}` : null,
`Source: ${getFileNameForDiagnostics(diagnostics.sourcePath)}`,
diagnostics.width && diagnostics.height
? `Output: ${diagnostics.width}x${diagnostics.height}${
diagnostics.frameRate ? ` @ ${diagnostics.frameRate} fps` : ""
}`
: null,
diagnostics.codec ? `Codec: ${diagnostics.codec}` : null,
diagnostics.bitrate ? `Bitrate: ${Math.round(diagnostics.bitrate / 1_000_000)} Mbps` : null,
`VideoEncoder: ${"VideoEncoder" in window ? "available" : "unavailable"}`,
].filter(Boolean);
return `${diagnostics.formatLabel} export failed\n${details.join("\n")}`;
}
function buildSaveDiagnosticMessage(formatLabel: "GIF" | "Video", reason?: string) {
return `${formatLabel} export save failed${reason ? `\nReason: ${reason}` : ""}`;
}
export default function VideoEditor() {
const {
state: editorState,
@@ -111,7 +177,6 @@ export default function VideoEditor() {
webcamMaskShape,
webcamSizePreset,
webcamPosition,
cursorHighlight,
} = editorState;
// ── Non-undoable state
@@ -129,8 +194,6 @@ export default function VideoEditor() {
currentTimeRef.current = currentTime;
const durationRef = useRef(duration);
durationRef.current = duration;
const [cursorTelemetry, setCursorTelemetry] = useState<CursorTelemetryPoint[]>([]);
const [cursorClickTimestamps, setCursorClickTimestamps] = useState<number[]>([]);
const [selectedZoomId, setSelectedZoomId] = useState<string | null>(null);
const [selectedTrimId, setSelectedTrimId] = useState<string | null>(null);
const [selectedSpeedId, setSelectedSpeedId] = useState<string | null>(null);
@@ -141,11 +204,15 @@ export default function VideoEditor() {
const [exportError, setExportError] = useState<string | null>(null);
const [showExportDialog, setShowExportDialog] = useState(false);
const [showNewRecordingDialog, setShowNewRecordingDialog] = useState(false);
const [exportQuality, setExportQuality] = useState<ExportQuality>("good");
const [exportFormat, setExportFormat] = useState<ExportFormat>("mp4");
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(15);
const [gifLoop, setGifLoop] = useState(true);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>("medium");
const [exportQuality, setExportQuality] = useState<ExportQuality>(
DEFAULT_EXPORT_SETTINGS.quality,
);
const [exportFormat, setExportFormat] = useState<ExportFormat>(DEFAULT_EXPORT_SETTINGS.format);
const [gifFrameRate, setGifFrameRate] = useState<GifFrameRate>(DEFAULT_GIF_SETTINGS.frameRate);
const [gifLoop, setGifLoop] = useState(DEFAULT_GIF_SETTINGS.loop);
const [gifSizePreset, setGifSizePreset] = useState<GifSizePreset>(
DEFAULT_GIF_SETTINGS.sizePreset,
);
const [exportedFilePath, setExportedFilePath] = useState<string | null>(null);
const [lastSavedSnapshot, setLastSavedSnapshot] = useState<string | null>(null);
const [unsavedExport, setUnsavedExport] = useState<{
@@ -155,8 +222,39 @@ export default function VideoEditor() {
} | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false);
const playerContainerRef = useRef<HTMLDivElement | null>(null);
const cursorTelemetrySourcePath = videoSourcePath ?? (videoPath ? fromFileUrl(videoPath) : null);
const { samples: cursorTelemetry, error: cursorTelemetryError } =
useCursorTelemetry(cursorTelemetrySourcePath);
const { data: cursorRecordingData, error: cursorRecordingDataError } =
useCursorRecordingData(cursorTelemetrySourcePath);
const cursorClickTimestamps = useMemo<number[]>(() => {
const recordingClicks =
cursorRecordingData?.samples
.filter((sample) => isClickInteractionType(sample.interactionType))
.map((sample) => sample.timeMs) ?? [];
if (recordingClicks.length > 0) {
return recordingClicks;
}
return cursorTelemetry
.filter((sample) => isClickInteractionType(sample.interactionType))
.map((sample) => sample.timeMs);
}, [cursorRecordingData, cursorTelemetry]);
// Cursor & motion blur visual settings (non-undoable preferences)
const [showCursor, setShowCursor] = useState(DEFAULT_CURSOR_SETTINGS.show);
const [cursorSize, setCursorSize] = useState(DEFAULT_CURSOR_SETTINGS.size);
const [cursorSmoothing, setCursorSmoothing] = useState(DEFAULT_CURSOR_SETTINGS.smoothing);
const [cursorMotionBlur, setCursorMotionBlur] = useState(DEFAULT_CURSOR_SETTINGS.motionBlur);
const [cursorClickBounce, setCursorClickBounce] = useState(DEFAULT_CURSOR_SETTINGS.clickBounce);
const [cursorClipToBounds, setCursorClipToBounds] = useState(
DEFAULT_CURSOR_SETTINGS.clipToBounds,
);
const [nativePlatform, setNativePlatform] = useState<NativePlatform | null>(null);
const [recordingCursorCaptureMode, setRecordingCursorCaptureMode] =
useState<CursorCaptureMode | null>(null);
const playerContainerRef = useRef<HTMLDivElement>(null);
const videoPlaybackRef = useRef<VideoPlaybackRef>(null);
const nextZoomIdRef = useRef(1);
@@ -164,12 +262,15 @@ export default function VideoEditor() {
const nextSpeedIdRef = useRef(1);
const { shortcuts, isMac } = useShortcuts();
// Off-Mac doesn't have click telemetry, so force `onlyOnClicks` off for
// renderers while keeping the persisted value intact for round-tripping.
const effectiveCursorHighlight = useMemo(
() => (isMac ? cursorHighlight : { ...cursorHighlight, onlyOnClicks: false }),
[cursorHighlight, isMac],
);
// Native Windows recordings include captured cursor assets. Native macOS
// recordings hide the system cursor in ScreenCaptureKit and use telemetry
// samples with OpenScreen's default arrow asset for the editable overlay.
const hasEditableCursorRecording =
recordingCursorCaptureMode === "editable-overlay" &&
(nativePlatform === "win32" || nativePlatform === "darwin") &&
hasNativeCursorRecordingData(cursorRecordingData);
const effectiveShowCursor = showCursor && hasEditableCursorRecording;
const showCursorSettings = hasEditableCursorRecording;
const { locale, setLocale, t: rawT } = useI18n();
const t = useScopedT("editor");
const ts = useScopedT("settings");
@@ -196,10 +297,18 @@ export default function VideoEditor() {
const webcamSourcePath =
webcamVideoSourcePath ?? (webcamVideoPath ? fromFileUrl(webcamVideoPath) : null);
return webcamSourcePath
? { screenVideoPath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath };
}, [videoPath, videoSourcePath, webcamVideoPath, webcamVideoSourcePath]);
return {
screenVideoPath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(recordingCursorCaptureMode ? { cursorCaptureMode: recordingCursorCaptureMode } : {}),
};
}, [
videoPath,
videoSourcePath,
webcamVideoPath,
webcamVideoSourcePath,
recordingCursorCaptureMode,
]);
const applyLoadedProject = useCallback(
async (candidate: unknown, path?: string | null) => {
@@ -208,13 +317,21 @@ export default function VideoEditor() {
}
const project = candidate;
const media = resolveProjectMedia(project);
if (!media) {
const projectMedia = resolveProjectMedia(project);
if (!projectMedia) {
return false;
}
const sourcePath = fromFileUrl(media.screenVideoPath);
const webcamSourcePath = media.webcamVideoPath ? fromFileUrl(media.webcamVideoPath) : null;
const sourcePath = projectMedia.screenVideoPath;
const webcamSourcePath = projectMedia.webcamVideoPath ?? null;
const projectCursorCaptureMode = projectMedia.cursorCaptureMode ?? null;
const normalizedEditor = normalizeProjectEditor(project.editor);
const inferredDurationMs = Math.max(
0,
...normalizedEditor.zoomRegions.map((region) => region.endMs),
...normalizedEditor.trimRegions.map((region) => region.endMs),
...normalizedEditor.speedRegions.map((region) => region.endMs),
...normalizedEditor.annotationRegions.map((region) => region.endMs),
);
try {
videoPlaybackRef.current?.pause();
@@ -223,13 +340,14 @@ export default function VideoEditor() {
}
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setDuration(inferredDurationMs > 0 ? inferredDurationMs / 1000 : 0);
setError(null);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setRecordingCursorCaptureMode(projectCursorCaptureMode);
setCurrentProjectPath(path ?? null);
pushState({
@@ -286,9 +404,11 @@ export default function VideoEditor() {
setLastSavedSnapshot(
createProjectSnapshot(
webcamSourcePath
? { screenVideoPath: sourcePath, webcamVideoPath: webcamSourcePath }
: { screenVideoPath: sourcePath },
{
screenVideoPath: sourcePath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(projectCursorCaptureMode ? { cursorCaptureMode: projectCursorCaptureMode } : {}),
},
normalizedEditor,
),
);
@@ -352,7 +472,7 @@ export default function VideoEditor() {
useEffect(() => {
async function loadInitialData() {
try {
const currentProjectResult = await window.electronAPI.loadCurrentProjectFile();
const currentProjectResult = await nativeBridgeClient.project.loadCurrentProjectFile();
if (currentProjectResult.success && currentProjectResult.project) {
const restored = await applyLoadedProject(
currentProjectResult.project,
@@ -374,31 +494,31 @@ export default function VideoEditor() {
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(webcamSourcePath);
setWebcamVideoPath(webcamSourcePath ? toFileUrl(webcamSourcePath) : null);
setRecordingCursorCaptureMode(session.cursorCaptureMode ?? null);
setCurrentProjectPath(null);
setLastSavedSnapshot(
createProjectSnapshot(
webcamSourcePath
? {
screenVideoPath: sourcePath,
webcamVideoPath: webcamSourcePath,
}
: { screenVideoPath: sourcePath },
{
screenVideoPath: sourcePath,
...(webcamSourcePath ? { webcamVideoPath: webcamSourcePath } : {}),
...(session.cursorCaptureMode
? { cursorCaptureMode: session.cursorCaptureMode }
: {}),
},
INITIAL_EDITOR_STATE,
),
);
return;
}
const result = await window.electronAPI.getCurrentVideoPath();
const result = await nativeBridgeClient.project.getCurrentVideoPath();
if (result.success && result.path) {
const sourcePath = fromFileUrl(result.path);
setVideoSourcePath(sourcePath);
setVideoPath(toFileUrl(sourcePath));
setWebcamVideoSourcePath(null);
setWebcamVideoPath(null);
setVideoSourcePath(result.path);
setVideoPath(toFileUrl(result.path));
setRecordingCursorCaptureMode(null);
setCurrentProjectPath(null);
setLastSavedSnapshot(
createProjectSnapshot({ screenVideoPath: sourcePath }, INITIAL_EDITOR_STATE),
createProjectSnapshot({ screenVideoPath: result.path }, INITIAL_EDITOR_STATE),
);
} else {
setError("No video to load. Please record or select a video.");
@@ -469,7 +589,6 @@ export default function VideoEditor() {
gifFrameRate,
gifLoop,
gifSizePreset,
cursorHighlight,
};
const projectData = createProjectData(currentProjectMedia, editorState);
@@ -481,7 +600,7 @@ export default function VideoEditor() {
// Match the normalization path used by `currentProjectSnapshot` so the
// post-save baseline compares equal and `hasUnsavedChanges` clears.
const projectSnapshot = createProjectSnapshot(currentProjectMedia, editorState);
const result = await window.electronAPI.saveProjectFile(
const result = await nativeBridgeClient.project.saveProjectFile(
projectData,
fileNameBase,
forceSaveAs ? undefined : (currentProjectPath ?? undefined),
@@ -531,7 +650,6 @@ export default function VideoEditor() {
videoPath,
t,
webcamSizePreset,
cursorHighlight,
],
);
@@ -587,7 +705,7 @@ export default function VideoEditor() {
}, []);
const handleLoadProject = useCallback(async () => {
const result = await window.electronAPI.loadProjectFile();
const result = await nativeBridgeClient.project.loadProjectFile();
if (result.canceled) {
return;
@@ -620,40 +738,37 @@ export default function VideoEditor() {
}, [handleLoadProject, handleSaveProject, handleSaveProjectAs]);
useEffect(() => {
let mounted = true;
async function loadCursorTelemetry() {
const sourcePath = currentProjectMedia?.screenVideoPath ?? null;
if (!sourcePath) {
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
let canceled = false;
nativeBridgeClient.system
.getPlatform()
.then((platform) => {
if (!canceled) {
setNativePlatform(platform);
}
return;
}
try {
const result = await window.electronAPI.getCursorTelemetry(sourcePath);
if (mounted) {
setCursorTelemetry(result.success ? result.samples : []);
setCursorClickTimestamps(result.success ? (result.clicks ?? []) : []);
})
.catch((error) => {
console.warn("Unable to resolve native platform for cursor settings:", error);
if (!canceled) {
setNativePlatform(null);
}
} catch (telemetryError) {
console.warn("Unable to load cursor telemetry:", telemetryError);
if (mounted) {
setCursorTelemetry([]);
setCursorClickTimestamps([]);
}
}
}
loadCursorTelemetry();
});
return () => {
mounted = false;
canceled = true;
};
}, [currentProjectMedia]);
}, []);
useEffect(() => {
if (cursorTelemetryError) {
console.warn("Unable to load cursor telemetry:", cursorTelemetryError);
}
}, [cursorTelemetryError]);
useEffect(() => {
if (cursorRecordingDataError) {
console.warn("Unable to load cursor recording data:", cursorRecordingDataError);
}
}, [cursorRecordingDataError]);
function togglePlayPause() {
const playback = videoPlaybackRef.current;
@@ -756,11 +871,10 @@ export default function VideoEditor() {
customScale: ZOOM_DEPTH_SCALES[DEFAULT_ZOOM_DEPTH],
focus: clampFocusToDepth(focus, DEFAULT_ZOOM_DEPTH),
};
// Bulk suggest must not steal selection — keeping a zoom selected hides
// the export panel (SettingsPanel gates it on !hasTimelineSelection),
// trapping users who just want to export after auto-zoom.
pushState((prev) => ({ zoomRegions: [...prev.zoomRegions, newRegion] }));
setSelectedZoomId(id);
setSelectedTrimId(null);
setSelectedAnnotationId(null);
setSelectedBlurId(null);
},
[pushState],
);
@@ -1411,11 +1525,21 @@ export default function VideoEditor() {
setUnsavedExport(null);
handleExportSaved(unsavedExport.format === "gif" ? "GIF" : "Video", saveResult.path);
} else {
toast.error(saveResult.message || "Failed to save export");
toast.error(
buildSaveDiagnosticMessage(
unsavedExport.format === "gif" ? "GIF" : "Video",
saveResult.message || "Failed to save export",
),
);
}
} catch (error) {
console.error("Error saving unsaved export:", error);
toast.error("Failed to save exported video");
toast.error(
buildSaveDiagnosticMessage(
unsavedExport.format === "gif" ? "GIF" : "Video",
error instanceof Error ? error.message : "Failed to save exported video",
),
);
}
}, [unsavedExport, handleExportSaved]);
@@ -1458,8 +1582,13 @@ export default function VideoEditor() {
videoPlaybackRef.current?.pause();
}
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
sourceWidth,
sourceHeight,
cropRegion,
);
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
@@ -1468,8 +1597,8 @@ export default function VideoEditor() {
// Get preview CONTAINER dimensions for scaling
const playbackRef = videoPlaybackRef.current;
const containerElement = playbackRef?.containerRef?.current;
const previewWidth = containerElement?.clientWidth || 1920;
const previewHeight = containerElement?.clientHeight || 1080;
const previewWidth = containerElement?.clientWidth || DEFAULT_SOURCE_DIMENSIONS.width;
const previewHeight = containerElement?.clientHeight || DEFAULT_SOURCE_DIMENSIONS.height;
if (settings.format === "gif" && settings.gifConfig) {
// GIF Export
@@ -1493,6 +1622,12 @@ export default function VideoEditor() {
padding,
videoPadding: padding,
cropRegion,
cursorRecordingData,
cursorScale: effectiveShowCursor ? cursorSize : 0,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
@@ -1502,7 +1637,6 @@ export default function VideoEditor() {
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1527,93 +1661,38 @@ export default function VideoEditor() {
handleExportSaved("GIF", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "gif" });
setExportError(saveResult.message || "Failed to save GIF");
toast.error(saveResult.message || "Failed to save GIF");
const message = buildSaveDiagnosticMessage(
"GIF",
saveResult.message || "Failed to save GIF",
);
setExportError(message);
toast.error(message);
}
} else {
setExportError(result.error || "GIF export failed");
toast.error(result.error || "GIF export failed");
const message = buildExportDiagnosticMessage({
formatLabel: "GIF",
reason: result.error || "GIF export failed",
sourcePath: videoSourcePath ?? videoPath,
width: settings.gifConfig.width,
height: settings.gifConfig.height,
frameRate: settings.gifConfig.frameRate,
});
setExportError(message);
toast.error(message);
}
} else {
// MP4 Export
const quality = settings.quality || exportQuality;
let exportWidth: number;
let exportHeight: number;
let bitrate: number;
if (quality === "source") {
exportWidth = sourceWidth;
exportHeight = sourceHeight;
// Use the source's longer dimension as the long axis of the export so
// a landscape recording can still fill a portrait target (and vice versa).
const sourceLongDim = Math.max(sourceWidth, sourceHeight);
if (aspectRatioValue === 1) {
const baseDimension = Math.floor(Math.min(sourceWidth, sourceHeight) / 2) * 2;
exportWidth = baseDimension;
exportHeight = baseDimension;
} else if (aspectRatioValue > 1) {
const baseWidth = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let w = baseWidth; w >= 100 && !found; w -= 2) {
const h = Math.round(w / aspectRatioValue);
if (h % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportWidth = baseWidth;
exportHeight = Math.floor(baseWidth / aspectRatioValue / 2) * 2;
}
} else {
const baseHeight = Math.floor(sourceLongDim / 2) * 2;
let found = false;
for (let h = baseHeight; h >= 100 && !found; h -= 2) {
const w = Math.round(h * aspectRatioValue);
if (w % 2 === 0 && Math.abs(w / h - aspectRatioValue) < 0.0001) {
exportWidth = w;
exportHeight = h;
found = true;
}
}
if (!found) {
exportHeight = baseHeight;
exportWidth = Math.floor((baseHeight * aspectRatioValue) / 2) * 2;
}
}
const totalPixels = exportWidth * exportHeight;
bitrate = 30_000_000;
if (totalPixels > 1920 * 1080 && totalPixels <= 2560 * 1440) {
bitrate = 50_000_000;
} else if (totalPixels > 2560 * 1440) {
bitrate = 80_000_000;
}
} else {
// Quality presets target the SHORT side; the long side derives from the
// aspect ratio. This keeps 1080p portrait at 1080×1920 instead of 607×1080.
const targetShortDim = quality === "medium" ? 720 : 1080;
if (aspectRatioValue >= 1) {
exportHeight = Math.floor(targetShortDim / 2) * 2;
exportWidth = Math.floor((exportHeight * aspectRatioValue) / 2) * 2;
} else {
exportWidth = Math.floor(targetShortDim / 2) * 2;
exportHeight = Math.floor(exportWidth / aspectRatioValue / 2) * 2;
}
const totalPixels = exportWidth * exportHeight;
if (totalPixels <= 1280 * 720) {
bitrate = 10_000_000;
} else if (totalPixels <= 1920 * 1080) {
bitrate = 20_000_000;
} else {
bitrate = 30_000_000;
}
}
const {
width: exportWidth,
height: exportHeight,
bitrate,
} = calculateMp4ExportSettings({
quality,
sourceWidth: effectiveSourceDimensions.width,
sourceHeight: effectiveSourceDimensions.height,
aspectRatioValue,
});
const exporter = new VideoExporter({
videoUrl: videoPath,
@@ -1634,6 +1713,12 @@ export default function VideoEditor() {
borderRadius,
padding,
cropRegion,
cursorRecordingData,
cursorScale: effectiveShowCursor ? cursorSize : 0,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
annotationRegions,
webcamLayoutPreset,
webcamMaskShape,
@@ -1643,7 +1728,6 @@ export default function VideoEditor() {
previewHeight,
cursorTelemetry,
cursorClickTimestamps,
cursorHighlight: effectiveCursorHighlight,
onProgress: (progress: ExportProgress) => {
setExportProgress(progress);
},
@@ -1668,12 +1752,26 @@ export default function VideoEditor() {
handleExportSaved("Video", saveResult.path);
} else {
setUnsavedExport({ arrayBuffer, fileName: targetFileName, format: "mp4" });
setExportError(saveResult.message || "Failed to save video");
toast.error(saveResult.message || "Failed to save video");
const message = buildSaveDiagnosticMessage(
"Video",
saveResult.message || "Failed to save video",
);
setExportError(message);
toast.error(message);
}
} else {
setExportError(result.error || "Export failed");
toast.error(result.error || "Export failed");
const message = buildExportDiagnosticMessage({
formatLabel: "Video",
reason: result.error || "Export failed",
sourcePath: videoSourcePath ?? videoPath,
width: exportWidth,
height: exportHeight,
frameRate: 60,
codec: "avc1.640033",
bitrate,
});
setExportError(message);
toast.error(message);
}
}
@@ -1688,8 +1786,13 @@ export default function VideoEditor() {
toast.error(message);
} else {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
setExportError(errorMessage);
toast.error(t("errors.exportFailedWithError", { error: errorMessage }));
const message = buildExportDiagnosticMessage({
formatLabel: settings.format === "gif" ? "GIF" : "Video",
reason: errorMessage,
sourcePath: videoSourcePath ?? videoPath,
});
setExportError(message);
toast.error(t("errors.exportFailedWithError", { error: message }));
}
} finally {
setIsExporting(false);
@@ -1702,6 +1805,7 @@ export default function VideoEditor() {
},
[
videoPath,
videoSourcePath,
webcamVideoPath,
wallpaper,
zoomRegions,
@@ -1713,6 +1817,7 @@ export default function VideoEditor() {
borderRadius,
padding,
cropRegion,
cursorRecordingData,
annotationRegions,
isPlaying,
aspectRatio,
@@ -1724,7 +1829,12 @@ export default function VideoEditor() {
handleExportSaved,
cursorTelemetry,
cursorClickTimestamps,
effectiveCursorHighlight,
effectiveShowCursor,
cursorSize,
cursorSmoothing,
cursorMotionBlur,
cursorClickBounce,
cursorClipToBounds,
t,
],
);
@@ -1742,15 +1852,20 @@ export default function VideoEditor() {
}
// Build export settings from current state
const sourceWidth = video.videoWidth || 1920;
const sourceHeight = video.videoHeight || 1080;
const sourceWidth = video.videoWidth || DEFAULT_SOURCE_DIMENSIONS.width;
const sourceHeight = video.videoHeight || DEFAULT_SOURCE_DIMENSIONS.height;
const effectiveSourceDimensions = calculateEffectiveSourceDimensions(
sourceWidth,
sourceHeight,
cropRegion,
);
const aspectRatioValue =
aspectRatio === "native"
? getNativeAspectRatioValue(sourceWidth, sourceHeight, cropRegion)
: getAspectRatioValue(aspectRatio);
const gifDimensions = calculateOutputDimensions(
sourceWidth,
sourceHeight,
effectiveSourceDimensions.width,
effectiveSourceDimensions.height,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatioValue,
@@ -1942,8 +2057,10 @@ export default function VideoEditor() {
aspectRatio:
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
videoPlaybackRef.current?.video?.videoWidth ||
DEFAULT_SOURCE_DIMENSIONS.width,
videoPlaybackRef.current?.video?.videoHeight ||
DEFAULT_SOURCE_DIMENSIONS.height,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
@@ -1980,6 +2097,7 @@ export default function VideoEditor() {
borderRadius={borderRadius}
padding={padding}
cropRegion={cropRegion}
cursorRecordingData={cursorRecordingData}
trimRegions={trimRegions}
speedRegions={speedRegions}
annotationRegions={annotationOnlyRegions}
@@ -1995,8 +2113,13 @@ export default function VideoEditor() {
onBlurDataChange={handleBlurDataPreviewChange}
onBlurDataCommit={commitState}
cursorTelemetry={cursorTelemetry}
cursorHighlight={effectiveCursorHighlight}
cursorClickTimestamps={cursorClickTimestamps}
showCursor={effectiveShowCursor}
cursorSize={cursorSize}
cursorSmoothing={cursorSmoothing}
cursorMotionBlur={cursorMotionBlur}
cursorClickBounce={cursorClickBounce}
cursorClipToBounds={cursorClipToBounds}
/>
</div>
</div>
@@ -2019,9 +2142,6 @@ export default function VideoEditor() {
<div className="editor-settings-rail min-w-0 h-full">
<SettingsPanel
cursorHighlight={cursorHighlight}
onCursorHighlightChange={(next) => pushState({ cursorHighlight: next })}
cursorHighlightSupportsClicks={isMac}
selected={wallpaper}
onWallpaperChange={(w) => pushState({ wallpaper: w })}
selectedZoomDepth={
@@ -2105,14 +2225,28 @@ export default function VideoEditor() {
gifSizePreset={gifSizePreset}
onGifSizePresetChange={setGifSizePreset}
gifOutputDimensions={calculateOutputDimensions(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
calculateEffectiveSourceDimensions(
videoPlaybackRef.current?.video?.videoWidth ||
DEFAULT_SOURCE_DIMENSIONS.width,
videoPlaybackRef.current?.video?.videoHeight ||
DEFAULT_SOURCE_DIMENSIONS.height,
cropRegion,
).width,
calculateEffectiveSourceDimensions(
videoPlaybackRef.current?.video?.videoWidth ||
DEFAULT_SOURCE_DIMENSIONS.width,
videoPlaybackRef.current?.video?.videoHeight ||
DEFAULT_SOURCE_DIMENSIONS.height,
cropRegion,
).height,
gifSizePreset,
GIF_SIZE_PRESETS,
aspectRatio === "native"
? getNativeAspectRatioValue(
videoPlaybackRef.current?.video?.videoWidth || 1920,
videoPlaybackRef.current?.video?.videoHeight || 1080,
videoPlaybackRef.current?.video?.videoWidth ||
DEFAULT_SOURCE_DIMENSIONS.width,
videoPlaybackRef.current?.video?.videoHeight ||
DEFAULT_SOURCE_DIMENSIONS.height,
cropRegion,
)
: getAspectRatioValue(aspectRatio),
@@ -2142,6 +2276,22 @@ export default function VideoEditor() {
unsavedExport={unsavedExport}
onSaveUnsavedExport={handleSaveUnsavedExport}
onSaveDiagnostic={handleSaveDiagnostic}
showCursor={showCursor}
onShowCursorChange={setShowCursor}
cursorSize={cursorSize}
onCursorSizeChange={setCursorSize}
cursorSmoothing={cursorSmoothing}
onCursorSmoothingChange={setCursorSmoothing}
cursorMotionBlur={cursorMotionBlur}
onCursorMotionBlurChange={setCursorMotionBlur}
cursorClickBounce={cursorClickBounce}
onCursorClickBounceChange={setCursorClickBounce}
cursorClipToBounds={cursorClipToBounds}
onCursorClipToBoundsChange={setCursorClipToBounds}
hasCursorData={
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
}
showCursorSettings={showCursorSettings}
/>
</div>
</div>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { parseCustomPlaybackSpeedInput } from "./customPlaybackSpeed";
describe("parseCustomPlaybackSpeedInput", () => {
it("accepts decimal playback speeds", () => {
expect(parseCustomPlaybackSpeedInput("1.1")).toEqual({
status: "valid",
draft: "1.1",
speed: 1.1,
});
});
it("keeps a single decimal point while typing", () => {
expect(parseCustomPlaybackSpeedInput("1.2.3")).toEqual({
status: "valid",
draft: "1.23",
speed: 1.23,
});
});
it("allows sub-1 custom speeds down to the editor minimum", () => {
expect(parseCustomPlaybackSpeedInput("0.1")).toEqual({
status: "valid",
draft: "0.1",
speed: 0.1,
});
});
it("rejects speeds below the editor minimum", () => {
expect(parseCustomPlaybackSpeedInput("0.09")).toEqual({
status: "too-slow",
draft: "0.09",
});
});
it("accepts comma decimal input by normalizing to a dot", () => {
expect(parseCustomPlaybackSpeedInput("1,1")).toEqual({
status: "valid",
draft: "1.1",
speed: 1.1,
});
});
it("rejects speeds above the editor maximum", () => {
expect(parseCustomPlaybackSpeedInput("16.1")).toEqual({
status: "too-fast",
draft: "16.1",
});
});
});
@@ -0,0 +1,37 @@
import {
clampPlaybackSpeed,
MAX_PLAYBACK_SPEED,
MIN_PLAYBACK_SPEED,
type PlaybackSpeed,
} from "./types";
export type CustomPlaybackSpeedInputResult =
| { status: "empty"; draft: string }
| { status: "too-fast"; draft: string }
| { status: "too-slow"; draft: string }
| { status: "valid"; draft: string; speed: PlaybackSpeed };
export function parseCustomPlaybackSpeedInput(rawValue: string): CustomPlaybackSpeedInputResult {
const decimalDraft = rawValue.replace(/,/g, ".").replace(/[^\d.]/g, "");
const [whole = "", ...fractionParts] = decimalDraft.split(".");
const draft = fractionParts.length > 0 ? `${whole}.${fractionParts.join("")}` : whole;
if (draft === "" || draft === ".") {
return { status: "empty", draft };
}
const speed = Number(draft);
if (!Number.isFinite(speed)) {
return { status: "empty", draft };
}
if (speed > MAX_PLAYBACK_SPEED) {
return { status: "too-fast", draft };
}
if (speed < MIN_PLAYBACK_SPEED) {
return { status: "too-slow", draft };
}
return { status: "valid", draft, speed: clampPlaybackSpeed(speed) };
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { INITIAL_EDITOR_STATE } from "@/hooks/useEditorHistory";
import { DEFAULT_PREFS } from "@/lib/userPreferences";
import {
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_WEBCAM_SETTINGS,
} from "./editorDefaults";
import { normalizeProjectEditor } from "./projectPersistence";
describe("editor defaults SSOT", () => {
it("keeps history defaults aligned with editor defaults", () => {
expect(INITIAL_EDITOR_STATE).toMatchObject({
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
});
});
it("keeps user preference defaults aligned with editor and export defaults", () => {
expect(DEFAULT_PREFS).toMatchObject({
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
});
});
it("keeps project fallback normalization aligned with editor defaults", () => {
expect(normalizeProjectEditor({})).toMatchObject({
...DEFAULT_EDITOR_APPEARANCE_SETTINGS,
padding: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
cropRegion: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion,
wallpaper: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
aspectRatio: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio,
webcamLayoutPreset: DEFAULT_WEBCAM_SETTINGS.layoutPreset,
webcamMaskShape: DEFAULT_WEBCAM_SETTINGS.maskShape,
webcamSizePreset: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: DEFAULT_WEBCAM_SETTINGS.position,
exportQuality: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: DEFAULT_EXPORT_SETTINGS.format,
gifFrameRate: DEFAULT_GIF_SETTINGS.frameRate,
gifLoop: DEFAULT_GIF_SETTINGS.loop,
gifSizePreset: DEFAULT_GIF_SETTINGS.sizePreset,
});
});
});
@@ -0,0 +1,95 @@
import type { ExportFormat, ExportQuality, GifFrameRate, GifSizePreset } from "@/lib/exporter";
import { DEFAULT_WALLPAPER } from "@/lib/wallpaper";
import type { AspectRatio } from "@/utils/aspectRatioUtils";
import {
type CursorVisualSettings,
DEFAULT_CROP_REGION,
DEFAULT_CURSOR_CLICK_BOUNCE,
DEFAULT_CURSOR_CLIP_TO_BOUNDS,
DEFAULT_CURSOR_MOTION_BLUR,
DEFAULT_CURSOR_SIZE,
DEFAULT_CURSOR_SMOOTHING,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
type WebcamLayoutPreset,
type WebcamMaskShape,
type WebcamPosition,
type WebcamSizePreset,
} from "./types";
export const DEFAULT_SOURCE_DIMENSIONS = {
width: 1920,
height: 1080,
} as const;
export const DEFAULT_GIF_OUTPUT_DIMENSIONS = {
width: 1280,
height: 720,
} as const;
export const DEFAULT_EDITOR_APPEARANCE_SETTINGS: {
shadowIntensity: number;
showBlur: boolean;
motionBlurAmount: number;
borderRadius: number;
} = {
shadowIntensity: 0,
showBlur: false,
motionBlurAmount: 0,
borderRadius: 0,
};
export const DEFAULT_EDITOR_LAYOUT_SETTINGS: {
padding: number;
aspectRatio: AspectRatio;
cropRegion: typeof DEFAULT_CROP_REGION;
wallpaper: string;
} = {
padding: 50,
aspectRatio: "16:9",
cropRegion: DEFAULT_CROP_REGION,
wallpaper: DEFAULT_WALLPAPER,
};
export const DEFAULT_WEBCAM_SETTINGS = {
layoutPreset: DEFAULT_WEBCAM_LAYOUT_PRESET,
maskShape: DEFAULT_WEBCAM_MASK_SHAPE,
sizePreset: DEFAULT_WEBCAM_SIZE_PRESET,
position: DEFAULT_WEBCAM_POSITION,
} as const satisfies {
layoutPreset: WebcamLayoutPreset;
maskShape: WebcamMaskShape;
sizePreset: WebcamSizePreset;
position: WebcamPosition | null;
};
export const DEFAULT_CURSOR_SETTINGS: CursorVisualSettings & { show: boolean } = {
show: true,
size: DEFAULT_CURSOR_SIZE,
smoothing: DEFAULT_CURSOR_SMOOTHING,
motionBlur: DEFAULT_CURSOR_MOTION_BLUR,
clickBounce: DEFAULT_CURSOR_CLICK_BOUNCE,
clipToBounds: DEFAULT_CURSOR_CLIP_TO_BOUNDS,
};
export const DEFAULT_EXPORT_SETTINGS: {
quality: ExportQuality;
format: ExportFormat;
} = {
quality: "good",
format: "mp4",
};
export const DEFAULT_GIF_SETTINGS: {
frameRate: GifFrameRate;
loop: boolean;
sizePreset: GifSizePreset;
outputDimensions: typeof DEFAULT_GIF_OUTPUT_DIMENSIONS;
} = {
frameRate: 15,
loop: true,
sizePreset: "medium",
outputDimensions: DEFAULT_GIF_OUTPUT_DIMENSIONS,
};
+55 -102
View File
@@ -4,6 +4,13 @@ import type { ProjectMedia } from "@/lib/recordingSession";
import { normalizeProjectMedia } from "@/lib/recordingSession";
import { DEFAULT_WALLPAPER, WALLPAPER_PATHS } from "@/lib/wallpaper";
import { ASPECT_RATIOS, type AspectRatio, isPortraitAspectRatio } from "@/utils/aspectRatioUtils";
import {
DEFAULT_EDITOR_APPEARANCE_SETTINGS,
DEFAULT_EDITOR_LAYOUT_SETTINGS,
DEFAULT_EXPORT_SETTINGS,
DEFAULT_GIF_SETTINGS,
DEFAULT_WEBCAM_SETTINGS,
} from "./editorDefaults";
import {
type AnnotationRegion,
type CropRegion,
@@ -15,14 +22,10 @@ import {
DEFAULT_BLUR_DATA,
DEFAULT_BLUR_FREEHAND_POINTS,
DEFAULT_BLUR_INTENSITY,
DEFAULT_CROP_REGION,
DEFAULT_FIGURE_DATA,
DEFAULT_PLAYBACK_SPEED,
DEFAULT_WEBCAM_LAYOUT_PRESET,
DEFAULT_WEBCAM_MASK_SHAPE,
DEFAULT_WEBCAM_POSITION,
DEFAULT_WEBCAM_SIZE_PRESET,
DEFAULT_ZOOM_DEPTH,
DEFAULT_ZOOM_MOTION_BLUR,
MAX_BLUR_BLOCK_SIZE,
MAX_BLUR_INTENSITY,
MAX_PLAYBACK_SPEED,
@@ -80,7 +83,6 @@ export interface ProjectEditorState {
gifFrameRate: GifFrameRate;
gifLoop: boolean;
gifSizePreset: GifSizePreset;
cursorHighlight: import("./videoPlayback/cursorHighlight").CursorHighlightConfig;
}
export interface EditorProjectData {
@@ -105,13 +107,13 @@ function computeNormalizedWebcamLayoutPreset(
case "vertical-stack":
return isPortraitAspectRatio(normalizedAspectRatio)
? webcamLayoutPreset
: DEFAULT_WEBCAM_LAYOUT_PRESET;
: DEFAULT_WEBCAM_SETTINGS.layoutPreset;
case "dual-frame":
return isPortraitAspectRatio(normalizedAspectRatio)
? DEFAULT_WEBCAM_LAYOUT_PRESET
? DEFAULT_WEBCAM_SETTINGS.layoutPreset
: webcamLayoutPreset;
default:
return DEFAULT_WEBCAM_LAYOUT_PRESET;
return DEFAULT_WEBCAM_SETTINGS.layoutPreset;
}
}
@@ -119,16 +121,14 @@ function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
function isFileUrl(value: string): boolean {
return /^file:\/\//i.test(value);
}
function encodePathSegments(pathname: string, keepWindowsDrive = false): string {
return pathname
.split("/")
.map((segment, index) => {
if (!segment) return "";
if (keepWindowsDrive && index === 1 && /^[a-zA-Z]:$/.test(segment)) {
if (!segment) {
return segment;
}
if (keepWindowsDrive && index === 0 && /^[a-zA-Z]:$/.test(segment)) {
return segment;
}
return encodeURIComponent(segment);
@@ -138,31 +138,25 @@ function encodePathSegments(pathname: string, keepWindowsDrive = false): string
export function toFileUrl(filePath: string): string {
const normalized = filePath.replace(/\\/g, "/");
// Windows drive path: C:/Users/...
if (/^[a-zA-Z]:\//.test(normalized)) {
return `file://${encodePathSegments(`/${normalized}`, true)}`;
if (normalized.match(/^[a-zA-Z]:/)) {
return `file:///${encodePathSegments(normalized, true)}`;
}
// UNC path: //server/share/...
if (normalized.startsWith("//")) {
const [host, ...pathParts] = normalized.replace(/^\/+/, "").split("/");
const encodedPath = pathParts.map((part) => encodeURIComponent(part)).join("/");
return encodedPath ? `file://${host}/${encodedPath}` : `file://${host}/`;
const withoutPrefix = normalized.slice(2);
const [host = "", ...segments] = withoutPrefix.split("/");
return `file://${host}/${encodePathSegments(segments.join("/"))}`;
}
const absolutePath = normalized.startsWith("/") ? normalized : `/${normalized}`;
return `file://${encodePathSegments(absolutePath)}`;
}
export function fromFileUrl(fileUrl: string): string {
const value = fileUrl.trim();
if (!isFileUrl(value)) {
if (!fileUrl.startsWith("file://")) {
return fileUrl;
}
try {
const url = new URL(value);
const url = new URL(fileUrl);
const pathname = decodeURIComponent(url.pathname);
if (url.host && url.host !== "localhost") {
@@ -175,13 +169,7 @@ export function fromFileUrl(fileUrl: string): string {
return pathname;
} catch {
const rawFallbackPath = value.replace(/^file:\/\//i, "");
let fallbackPath = rawFallbackPath;
try {
fallbackPath = decodeURIComponent(rawFallbackPath);
} catch {
// Keep raw best-effort path if percent decoding fails.
}
const fallbackPath = decodeURIComponent(fileUrl.replace(/^file:\/\//, ""));
return fallbackPath.replace(/^\/([a-zA-Z]:)/, "$1");
}
}
@@ -226,7 +214,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.aspectRatio as AspectRatio,
)
? (editor.aspectRatio as AspectRatio)
: "16:9";
: DEFAULT_EDITOR_LAYOUT_SETTINGS.aspectRatio;
const normalizedWebcamLayoutPreset = computeNormalizedWebcamLayoutPreset(
editor.webcamLayoutPreset,
normalizedAspectRatio,
@@ -241,7 +229,7 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
cx: clamp((editor.webcamPosition as WebcamPosition).cx, 0, 1),
cy: clamp((editor.webcamPosition as WebcamPosition).cy, 0, 1),
}
: DEFAULT_WEBCAM_POSITION;
: DEFAULT_WEBCAM_SETTINGS.position;
const normalizedZoomRegions: ZoomRegion[] = Array.isArray(editor.zoomRegions)
? editor.zoomRegions
@@ -428,16 +416,16 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
const rawCropX = isFiniteNumber(editor.cropRegion?.x)
? editor.cropRegion.x
: DEFAULT_CROP_REGION.x;
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.x;
const rawCropY = isFiniteNumber(editor.cropRegion?.y)
? editor.cropRegion.y
: DEFAULT_CROP_REGION.y;
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.y;
const rawCropWidth = isFiniteNumber(editor.cropRegion?.width)
? editor.cropRegion.width
: DEFAULT_CROP_REGION.width;
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.width;
const rawCropHeight = isFiniteNumber(editor.cropRegion?.height)
? editor.cropRegion.height
: DEFAULT_CROP_REGION.height;
: DEFAULT_EDITOR_LAYOUT_SETTINGS.cropRegion.height;
const cropX = clamp(rawCropX, 0, 1);
const cropY = clamp(rawCropY, 0, 1);
@@ -448,18 +436,29 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
wallpaper:
typeof editor.wallpaper === "string"
? normalizeWallpaperValue(editor.wallpaper)
: DEFAULT_WALLPAPER,
shadowIntensity: typeof editor.shadowIntensity === "number" ? editor.shadowIntensity : 0,
showBlur: typeof editor.showBlur === "boolean" ? editor.showBlur : false,
: DEFAULT_EDITOR_LAYOUT_SETTINGS.wallpaper,
shadowIntensity:
typeof editor.shadowIntensity === "number"
? editor.shadowIntensity
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.shadowIntensity,
showBlur:
typeof editor.showBlur === "boolean"
? editor.showBlur
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.showBlur,
motionBlurAmount: isFiniteNumber(editor.motionBlurAmount)
? clamp(editor.motionBlurAmount, 0, 1)
: typeof (editor as { motionBlurEnabled?: unknown }).motionBlurEnabled === "boolean"
? (editor as { motionBlurEnabled?: boolean }).motionBlurEnabled
? 0.35
: 0
: 0,
borderRadius: typeof editor.borderRadius === "number" ? editor.borderRadius : 0,
padding: isFiniteNumber(editor.padding) ? clamp(editor.padding, 0, 100) : 50,
? DEFAULT_ZOOM_MOTION_BLUR
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.motionBlurAmount,
borderRadius:
typeof editor.borderRadius === "number"
? editor.borderRadius
: DEFAULT_EDITOR_APPEARANCE_SETTINGS.borderRadius,
padding: isFiniteNumber(editor.padding)
? clamp(editor.padding, 0, 100)
: DEFAULT_EDITOR_LAYOUT_SETTINGS.padding,
cropRegion: {
x: cropX,
y: cropY,
@@ -478,77 +477,31 @@ export function normalizeProjectEditor(editor: Partial<ProjectEditorState>): Pro
editor.webcamMaskShape === "square" ||
editor.webcamMaskShape === "rounded"
? editor.webcamMaskShape
: DEFAULT_WEBCAM_MASK_SHAPE,
: DEFAULT_WEBCAM_SETTINGS.maskShape,
webcamSizePreset:
typeof editor.webcamSizePreset === "number" && isFiniteNumber(editor.webcamSizePreset)
? Math.max(10, Math.min(50, editor.webcamSizePreset))
: DEFAULT_WEBCAM_SIZE_PRESET,
: DEFAULT_WEBCAM_SETTINGS.sizePreset,
webcamPosition: normalizedWebcamPosition,
exportQuality:
editor.exportQuality === "medium" || editor.exportQuality === "source"
? editor.exportQuality
: "good",
exportFormat: editor.exportFormat === "gif" ? "gif" : "mp4",
: DEFAULT_EXPORT_SETTINGS.quality,
exportFormat: editor.exportFormat === "gif" ? "gif" : DEFAULT_EXPORT_SETTINGS.format,
gifFrameRate:
editor.gifFrameRate === 15 ||
editor.gifFrameRate === 20 ||
editor.gifFrameRate === 25 ||
editor.gifFrameRate === 30
? editor.gifFrameRate
: 15,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : true,
: DEFAULT_GIF_SETTINGS.frameRate,
gifLoop: typeof editor.gifLoop === "boolean" ? editor.gifLoop : DEFAULT_GIF_SETTINGS.loop,
gifSizePreset:
editor.gifSizePreset === "medium" ||
editor.gifSizePreset === "large" ||
editor.gifSizePreset === "original"
? editor.gifSizePreset
: "medium",
cursorHighlight: normalizeCursorHighlight(editor.cursorHighlight),
};
}
function normalizeCursorHighlight(
value: unknown,
): import("./videoPlayback/cursorHighlight").CursorHighlightConfig {
const fallback: import("./videoPlayback/cursorHighlight").CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
if (!value || typeof value !== "object") return fallback;
const v = value as Partial<import("./videoPlayback/cursorHighlight").CursorHighlightConfig>;
return {
enabled: typeof v.enabled === "boolean" ? v.enabled : fallback.enabled,
style: v.style === "dot" || v.style === "ring" ? v.style : fallback.style,
sizePx:
typeof v.sizePx === "number" && v.sizePx >= 10 && v.sizePx <= 36 ? v.sizePx : fallback.sizePx,
color:
typeof v.color === "string" && /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v.color)
? v.color
: fallback.color,
opacity:
typeof v.opacity === "number" && v.opacity >= 0 && v.opacity <= 1
? v.opacity
: fallback.opacity,
onlyOnClicks: typeof v.onlyOnClicks === "boolean" ? v.onlyOnClicks : fallback.onlyOnClicks,
clickEmphasisDurationMs:
typeof v.clickEmphasisDurationMs === "number" && v.clickEmphasisDurationMs > 0
? v.clickEmphasisDurationMs
: fallback.clickEmphasisDurationMs,
offsetXNorm:
typeof v.offsetXNorm === "number" && Number.isFinite(v.offsetXNorm)
? Math.max(-1, Math.min(1, v.offsetXNorm))
: fallback.offsetXNorm,
offsetYNorm:
typeof v.offsetYNorm === "number" && Number.isFinite(v.offsetYNorm)
? Math.max(-1, Math.min(1, v.offsetYNorm))
: fallback.offsetYNorm,
: DEFAULT_GIF_SETTINGS.sizePreset,
};
}
@@ -51,6 +51,7 @@ const SUGGESTION_SPACING_MS = 1800;
interface TimelineEditorProps {
videoDuration: number;
hasVideoSource?: boolean;
currentTime: number;
onSeek?: (time: number) => void;
cursorTelemetry?: CursorTelemetryPoint[];
@@ -236,6 +237,31 @@ function formatPlayheadTime(ms: number): string {
return `${sec.toFixed(1)}s`;
}
function shouldStartTimelineScrub(target: EventTarget | null, timelineElement: HTMLElement) {
if (!(target instanceof HTMLElement)) {
return false;
}
for (let element: HTMLElement | null = target; element && element !== timelineElement; ) {
const className = element.className;
const classText = typeof className === "string" ? className : "";
if (
classText.split(/\s+/).includes("group") ||
classText.includes("cursor-grab") ||
classText.includes("cursor-grabbing") ||
classText.includes("cursor-ew-resize") ||
element.style.cursor === "col-resize"
) {
return false;
}
element = element.parentElement;
}
return true;
}
function PlaybackCursor({
currentTimeMs,
videoDurationMs,
@@ -562,6 +588,8 @@ function Timeline({
const t = useScopedT("timeline");
const { setTimelineRef, style, sidebarWidth, range, pixelsToValue } = useTimelineContext();
const localTimelineRef = useRef<HTMLDivElement | null>(null);
const isScrubbingTimelineRef = useRef(false);
const scrubPointerIdRef = useRef<number | null>(null);
const setRefs = useCallback(
(node: HTMLDivElement | null) => {
@@ -571,43 +599,105 @@ function Timeline({
[setTimelineRef],
);
const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!onSeek || videoDurationMs <= 0) return;
const seekTimelineAtClientX = useCallback(
(timelineElement: HTMLDivElement, clientX: number) => {
if (!onSeek || videoDurationMs <= 0) return false;
// Only clear selection if clicking on empty space (not on items)
// This is handled by event propagation - items stop propagation
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
const rect = timelineElement.getBoundingClientRect();
const clickX = clientX - rect.left - sidebarWidth;
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left - sidebarWidth;
if (clickX < 0) return;
if (clickX < 0) return false;
const relativeMs = pixelsToValue(clickX);
const absoluteMs = Math.max(0, Math.min(range.start + relativeMs, videoDurationMs));
const timeInSeconds = absoluteMs / 1000;
onSeek(timeInSeconds);
onSeek(absoluteMs / 1000);
return true;
},
[
onSeek,
onSelectZoom,
onSelectTrim,
onSelectAnnotation,
onSelectBlur,
onSelectSpeed,
videoDurationMs,
sidebarWidth,
range.start,
pixelsToValue,
],
[onSeek, videoDurationMs, sidebarWidth, pixelsToValue, range.start],
);
const clearTimelineSelection = useCallback(() => {
onSelectZoom?.(null);
onSelectTrim?.(null);
onSelectAnnotation?.(null);
onSelectBlur?.(null);
onSelectSpeed?.(null);
}, [onSelectZoom, onSelectTrim, onSelectAnnotation, onSelectBlur, onSelectSpeed]);
const handleTimelineClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only clear selection if clicking on empty space (not on items)
// This is handled by event propagation - items stop propagation
clearTimelineSelection();
seekTimelineAtClientX(e.currentTarget, e.clientX);
},
[clearTimelineSelection, seekTimelineAtClientX],
);
const handleTimelinePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!e.isPrimary || (e.pointerType === "mouse" && e.button !== 0)) {
return;
}
if (!shouldStartTimelineScrub(e.target, e.currentTarget)) {
return;
}
if (!seekTimelineAtClientX(e.currentTarget, e.clientX)) {
return;
}
clearTimelineSelection();
isScrubbingTimelineRef.current = true;
scrubPointerIdRef.current = e.pointerId;
e.currentTarget.setPointerCapture(e.pointerId);
e.preventDefault();
},
[clearTimelineSelection, seekTimelineAtClientX],
);
const handleTimelinePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
return;
}
seekTimelineAtClientX(e.currentTarget, e.clientX);
e.preventDefault();
},
[seekTimelineAtClientX],
);
const stopTimelineScrub = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!isScrubbingTimelineRef.current || scrubPointerIdRef.current !== e.pointerId) {
return;
}
isScrubbingTimelineRef.current = false;
scrubPointerIdRef.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}, []);
const handleTimelinePointerLeave = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (isScrubbingTimelineRef.current && scrubPointerIdRef.current === e.pointerId) {
seekTimelineAtClientX(e.currentTarget, e.clientX);
}
},
[seekTimelineAtClientX],
);
const handleTimelineLostPointerCapture = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (scrubPointerIdRef.current === e.pointerId) {
isScrubbingTimelineRef.current = false;
scrubPointerIdRef.current = null;
}
}, []);
const handleTimelineWheel = useCallback(
(event: React.WheelEvent<HTMLDivElement>) => {
if (!onRangeChange || event.ctrlKey || event.metaKey || videoDurationMs <= 0) {
@@ -657,9 +747,15 @@ function Timeline({
return (
<div
ref={setRefs}
style={style}
style={{ ...style, touchAction: "none" }}
className="select-none bg-[#0b0c0f] min-h-[190px] relative cursor-pointer group"
onClick={handleTimelineClick}
onPointerDown={handleTimelinePointerDown}
onPointerMove={handleTimelinePointerMove}
onPointerUp={stopTimelineScrub}
onPointerCancel={stopTimelineScrub}
onPointerLeave={handleTimelinePointerLeave}
onLostPointerCapture={handleTimelineLostPointerCapture}
onWheel={handleTimelineWheel}
>
<div className="absolute inset-0 bg-[linear-gradient(to_right,#ffffff05_1px,transparent_1px)] bg-[length:24px_100%] pointer-events-none" />
@@ -766,6 +862,7 @@ function Timeline({
export default function TimelineEditor({
videoDuration,
hasVideoSource = false,
currentTime,
onSeek,
cursorTelemetry = [],
@@ -1439,8 +1536,14 @@ export default function TimelineEditor({
<Plus className="w-6 h-6 text-slate-600" />
</div>
<div className="text-center">
<p className="text-sm font-medium text-slate-300">{t("emptyState.noVideo")}</p>
<p className="text-xs text-slate-500 mt-1">{t("emptyState.dragAndDrop")}</p>
<p className="text-sm font-medium text-slate-300">
{hasVideoSource ? "Loading Timeline" : "No Video Loaded"}
</p>
<p className="text-xs text-slate-500 mt-1">
{hasVideoSource
? "Video opened, waiting for duration metadata"
: "Drag and drop a video to start editing"}
</p>
</div>
</div>
);
+28
View File
@@ -170,8 +170,36 @@ 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;
clipToBounds: boolean;
}
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;
// false = allow the cursor to overflow into the background by default.
// true = clip the native cursor to the video canvas bounds.
export const DEFAULT_CURSOR_CLIP_TO_BOUNDS = false;
export const DEFAULT_ZOOM_MOTION_BLUR = 0.35;
export interface TrimRegion {
id: string;
startMs: number;
@@ -1,125 +0,0 @@
import type { Graphics } from "pixi.js";
export type CursorHighlightStyle = "dot" | "ring";
export interface CursorHighlightConfig {
enabled: boolean;
style: CursorHighlightStyle;
sizePx: number;
color: string;
opacity: number;
// Show only on clicks (macOS — depends on click telemetry from uiohook).
onlyOnClicks: boolean;
clickEmphasisDurationMs: number;
// Per-recording manual nudge. Cursor telemetry is normalized to the display,
// but window recordings frame a subset of the display so the highlight
// lands offset. Users dial these in once to align with the actual cursor.
offsetXNorm: number;
offsetYNorm: number;
}
export const CURSOR_HIGHLIGHT_MIN_SIZE_PX = 10;
export const CURSOR_HIGHLIGHT_MAX_SIZE_PX = 36;
export const DEFAULT_CURSOR_HIGHLIGHT: CursorHighlightConfig = {
enabled: false,
style: "ring",
sizePx: 24,
color: "#FFD700",
opacity: 0.9,
onlyOnClicks: false,
clickEmphasisDurationMs: 350,
offsetXNorm: 0,
offsetYNorm: 0,
};
export const CURSOR_HIGHLIGHT_OFFSET_RANGE = 0.25; // ±25% of recorded surface
// Alpha multiplier for the highlight at `timeMs`. Returns 1 when not in
// click-only mode; in click-only mode fades 1→0 across each click's window.
export function clickEmphasisAlpha(
timeMs: number,
clickTimestampsMs: number[] | undefined,
config: CursorHighlightConfig,
): number {
if (!config.onlyOnClicks) return 1;
if (!clickTimestampsMs || clickTimestampsMs.length === 0) return 0;
const window = Math.max(1, config.clickEmphasisDurationMs);
for (let i = 0; i < clickTimestampsMs.length; i++) {
const dt = timeMs - clickTimestampsMs[i];
if (dt >= 0 && dt <= window) {
return 1 - dt / window;
}
}
return 0;
}
function parseHexColor(hex: string): number {
const cleaned = hex.replace("#", "");
if (cleaned.length === 3) {
const r = cleaned[0];
const g = cleaned[1];
const b = cleaned[2];
return Number.parseInt(`${r}${r}${g}${g}${b}${b}`, 16);
}
return Number.parseInt(cleaned.slice(0, 6), 16);
}
export function drawCursorHighlightGraphics(g: Graphics, config: CursorHighlightConfig): void {
g.clear();
if (!config.enabled) return;
const color = parseHexColor(config.color);
const radius = Math.max(1, config.sizePx / 2);
const alpha = Math.max(0, Math.min(1, config.opacity));
switch (config.style) {
case "dot": {
g.circle(0, 0, radius);
g.fill({ color, alpha });
break;
}
case "ring": {
g.circle(0, 0, radius);
g.stroke({ color, alpha, width: Math.max(2, radius * 0.18) });
break;
}
}
}
export function drawCursorHighlightCanvas(
ctx: CanvasRenderingContext2D,
cx: number,
cy: number,
config: CursorHighlightConfig,
pixelScale = 1,
): void {
if (!config.enabled) return;
const radius = Math.max(1, (config.sizePx / 2) * pixelScale);
const alpha = Math.max(0, Math.min(1, config.opacity));
const color = config.color;
ctx.save();
ctx.globalAlpha = alpha;
switch (config.style) {
case "dot": {
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
break;
}
case "ring": {
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(2, radius * 0.18);
ctx.stroke();
break;
}
}
ctx.restore();
}
@@ -0,0 +1,768 @@
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 { UPLOADED_CURSOR_SAMPLE_SIZE, uploadedCursorAssets } from "./uploadedCursorAssets";
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
/** System cursor asset from native helper (macOS only). */
type SystemCursorAsset = {
dataUrl: string;
width: number;
height: number;
hotspotX: number;
hotspotY: number;
};
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 (01) */
dotAlpha: number;
/** Unused, kept for interface compatibility */
trailLength: number;
/** Smoothing factor for cursor interpolation (01, 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<void> | null = null;
let loadedCursorAssets: Partial<Record<CursorAssetKey, LoadedCursorAsset>> = {};
const SUPPORTED_CURSOR_KEYS: CursorAssetKey[] = [
"arrow",
"text",
"pointer",
"crosshair",
"open-hand",
"closed-hand",
"resize-ew",
"resize-ns",
"not-allowed",
];
function loadImage(dataUrl: string) {
return new Promise<HTMLImageElement>((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () =>
reject(new Error(`Failed to load cursor image: ${dataUrl.slice(0, 128)}`));
image.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<string, SystemCursorAsset> = {};
try {
const api = window.electronAPI as Record<string, unknown>;
if (typeof api.getSystemCursorAssets === "function") {
const result = await (
api.getSystemCursorAssets as () => Promise<{
success: boolean;
cursors?: Record<string, SystemCursorAsset>;
}>
)();
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<Record<CursorAssetKey, LoadedCursorAsset>>;
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<CursorRenderConfig, "smoothingFactor" | "trailLength">) {
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<Record<CursorAssetKey, Sprite>>;
private cursorShadowFilters: Partial<Record<CursorAssetKey, BlurFilter>>;
private cursorSprites: Partial<Record<CursorAssetKey, Sprite>>;
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<CursorRenderConfig> = {}) {
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 });
cursorAssetsPromise = null;
loadedCursorAssets = {};
}
}
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();
}
@@ -32,6 +32,7 @@ interface LayoutResult {
baseScale: number;
baseOffset: { x: number; y: number };
maskRect: RenderRect;
maskBorderRadius: number;
webcamRect: StyledRenderRect | null;
cropBounds: { startX: number; endX: number; startY: number; endY: number };
}
@@ -150,6 +151,8 @@ export function layoutVideoContent(params: LayoutParams): LayoutResult | null {
baseScale: scale,
baseOffset: { x: spriteX, y: spriteY },
maskRect: compositeLayout.screenRect,
maskBorderRadius:
compositeLayout.screenBorderRadius ?? (compositeLayout.screenCover ? 0 : borderRadius),
webcamRect: compositeLayout.webcamRect,
cropBounds: { startX: cropStartX, endX: cropEndX, startY: cropStartY, endY: cropEndY },
};
@@ -0,0 +1,149 @@
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,
};
}
@@ -0,0 +1,70 @@
import crosshairUrl from "../../../assets/cursors/Cursor=Cross.svg";
import arrowUrl from "../../../assets/cursors/Cursor=Default.svg";
import closedHandUrl from "../../../assets/cursors/Cursor=Hand-(Grabbing).svg";
import openHandUrl from "../../../assets/cursors/Cursor=Hand-(Open).svg";
import pointerUrl from "../../../assets/cursors/Cursor=Hand-(Pointing).svg";
import resizeNsUrl from "../../../assets/cursors/Cursor=Resize-North-South.svg";
import resizeEwUrl from "../../../assets/cursors/Cursor=Resize-West-East.svg";
import textUrl from "../../../assets/cursors/Cursor=Text-Cursor.svg";
import type { CursorTelemetryPoint } from "../types";
type CursorAssetKey = NonNullable<CursorTelemetryPoint["cursorType"]>;
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<Record<CursorAssetKey, UploadedCursorAsset>> = {
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 },
},
};