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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 (0–1) */
|
||||
dotAlpha: number;
|
||||
/** Unused, kept for interface compatibility */
|
||||
trailLength: number;
|
||||
/** Smoothing factor for cursor interpolation (0–1, lower = smoother/slower) */
|
||||
smoothingFactor: number;
|
||||
/** Directional cursor motion blur amount. */
|
||||
motionBlur: number;
|
||||
/** Click bounce multiplier. */
|
||||
clickBounce: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CURSOR_CONFIG: CursorRenderConfig = {
|
||||
dotRadius: 28,
|
||||
dotColor: 0xffffff,
|
||||
dotAlpha: 0.95,
|
||||
trailLength: 0,
|
||||
smoothingFactor: 0.18,
|
||||
motionBlur: 0,
|
||||
clickBounce: 1,
|
||||
};
|
||||
|
||||
const REFERENCE_WIDTH = 1920;
|
||||
const MIN_CURSOR_VIEWPORT_SCALE = 0.55;
|
||||
const CLICK_ANIMATION_MS = 140;
|
||||
const CLICK_RING_FADE_MS = 240;
|
||||
const CURSOR_MOTION_BLUR_BASE_MULTIPLIER = 0.08;
|
||||
const CURSOR_TIME_DISCONTINUITY_MS = 100;
|
||||
const CURSOR_SVG_DROP_SHADOW_FILTER = "drop-shadow(0px 2px 3px rgba(0, 0, 0, 0.35))";
|
||||
const CURSOR_SHADOW_COLOR = 0x000000;
|
||||
const CURSOR_SHADOW_ALPHA = 0.35;
|
||||
const CURSOR_SHADOW_OFFSET_X = 0;
|
||||
const CURSOR_SHADOW_OFFSET_Y = 2;
|
||||
const CURSOR_SHADOW_BLUR = 3;
|
||||
const CURSOR_SHADOW_PADDING = 12;
|
||||
|
||||
let cursorAssetsPromise: Promise<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 },
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user