fix: restore native cursor wiring after upstream rebase
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
@@ -963,7 +962,9 @@ export function registerIpcHandlers(
|
||||
// is triggered by desktopCapturer.getSources(). Fire it and return so
|
||||
// the renderer can re-check status after the user responds.
|
||||
if (status === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {
|
||||
// Permission probing failure is reported by the explicit status check below.
|
||||
});
|
||||
return { success: true, granted: false, status: "not-determined" };
|
||||
}
|
||||
|
||||
@@ -1526,7 +1527,7 @@ export function registerIpcHandlers(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
path: result.filePath,
|
||||
path: normalizedPath,
|
||||
message: "Video exported successfully",
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -1911,4 +1912,21 @@ export function registerIpcHandlers(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
registerNativeBridgeHandlers({
|
||||
getPlatform: () => process.platform,
|
||||
getCurrentProjectPath: () => currentProjectPath,
|
||||
getCurrentVideoPath: () => currentVideoPath,
|
||||
saveProjectFile,
|
||||
loadProjectFile,
|
||||
loadCurrentProjectFile,
|
||||
setCurrentVideoPath,
|
||||
getCurrentVideoPathResult,
|
||||
clearCurrentVideoPath,
|
||||
resolveAssetBasePath,
|
||||
resolveVideoPath: (videoPath?: string | null) =>
|
||||
normalizeVideoSourcePath(videoPath ?? currentVideoPath),
|
||||
loadCursorRecordingData: readCursorRecordingFile,
|
||||
loadCursorTelemetry: readCursorTelemetryFile,
|
||||
});
|
||||
}
|
||||
|
||||
+4
-2
@@ -13,7 +13,7 @@ import {
|
||||
Tray,
|
||||
} from "electron";
|
||||
import { mainT, setMainLocale } from "./i18n";
|
||||
import { getSelectedDesktopSource, registerIpcHandlers } from "./ipc/handlers";
|
||||
import { registerIpcHandlers } from "./ipc/handlers";
|
||||
import {
|
||||
createCountdownOverlayWindow,
|
||||
createEditorWindow,
|
||||
@@ -490,7 +490,9 @@ app.whenReady().then(async () => {
|
||||
// driven by later getSources() calls (fixes repeated permission dialog).
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus("screen");
|
||||
if (screenStatus === "not-determined") {
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
|
||||
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {
|
||||
// This only triggers the system prompt; permission state is read separately.
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -290,7 +290,6 @@ export function LaunchWindow() {
|
||||
|
||||
const [selectedSource, setSelectedSource] = useState("Screen");
|
||||
const [hasSelectedSource, setHasSelectedSource] = useState(false);
|
||||
const [, setHudPointerDownCount] = useState(0);
|
||||
const [, setRecordPointerDownCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -309,6 +309,19 @@ 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;
|
||||
hasCursorData?: boolean;
|
||||
showCursorSettings?: boolean;
|
||||
showCursorHighlightSettings?: boolean;
|
||||
}
|
||||
|
||||
export default SettingsPanel;
|
||||
@@ -405,6 +418,19 @@ export function SettingsPanel({
|
||||
onWebcamSizePresetChange,
|
||||
onWebcamSizePresetCommit,
|
||||
onSaveDiagnostic,
|
||||
showCursor = true,
|
||||
onShowCursorChange,
|
||||
cursorSize = 3.0,
|
||||
onCursorSizeChange,
|
||||
cursorSmoothing = 0.67,
|
||||
onCursorSmoothingChange,
|
||||
cursorMotionBlur = 0.35,
|
||||
onCursorMotionBlurChange,
|
||||
cursorClickBounce = 2.5,
|
||||
onCursorClickBounceChange,
|
||||
hasCursorData = false,
|
||||
showCursorSettings = true,
|
||||
showCursorHighlightSettings = true,
|
||||
}: SettingsPanelProps) {
|
||||
const t = useScopedT("settings");
|
||||
const [activePanelMode, setActivePanelMode] = useState<SettingsPanelMode>("background");
|
||||
@@ -536,10 +562,14 @@ 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) ||
|
||||
(showCursorHighlightSettings && Boolean(cursorHighlight && onCursorHighlightChange));
|
||||
const panelModes: Array<{
|
||||
id: SettingsPanelMode;
|
||||
label: string;
|
||||
@@ -549,7 +579,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.cursorHighlight.title"),
|
||||
icon: MousePointerClick,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
const exportPanelMode = {
|
||||
id: "export" as const,
|
||||
@@ -1359,220 +1397,312 @@ 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")}
|
||||
</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"
|
||||
<div className="text-[10px] font-medium text-slate-300">Show Cursor</div>
|
||||
<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")}
|
||||
{showCursor && (
|
||||
<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">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">
|
||||
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">
|
||||
Motion Blur
|
||||
</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">
|
||||
Click Bounce
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activePanelMode === "cursor" &&
|
||||
showCursorHighlightSettings &&
|
||||
cursorHighlight &&
|
||||
onCursorHighlightChange && (
|
||||
<div className="p-2 rounded-lg editor-control-surface mt-2 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[10px] font-medium text-slate-300">
|
||||
{t("effects.cursorHighlight.title")}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
onClick={() =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
onlyOnClicks: turningOn,
|
||||
});
|
||||
}}
|
||||
enabled: !cursorHighlight.enabled,
|
||||
})
|
||||
}
|
||||
className={`text-[10px] px-2 py-0.5 rounded border transition-colors ${
|
||||
cursorHighlight.onlyOnClicks
|
||||
cursorHighlight.enabled
|
||||
? "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")}
|
||||
{cursorHighlight.enabled ? 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={`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"
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
{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"
|
||||
/>
|
||||
</div>
|
||||
{cursorHighlightSupportsClicks && (
|
||||
<div
|
||||
className={`flex items-center justify-between ${cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"}`}
|
||||
>
|
||||
<ColorPicker
|
||||
selectedColor={cursorHighlight.color}
|
||||
colorPalette={colorPalette}
|
||||
translations={{
|
||||
colorWheel: t("background.colorWheel"),
|
||||
colorPalette: t("background.colorPalette"),
|
||||
}}
|
||||
onUpdateColor={(color) =>
|
||||
<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,
|
||||
color,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</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")}
|
||||
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>
|
||||
<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],
|
||||
})
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
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="text-[10px] text-slate-400 mb-1">
|
||||
{t("effects.cursorHighlight.color")}
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">
|
||||
{(cursorHighlight.offsetYNorm * 100).toFixed(1)}%
|
||||
</span>
|
||||
<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,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<Slider
|
||||
value={[cursorHighlight.offsetYNorm]}
|
||||
onValueChange={(values) =>
|
||||
onCursorHighlightChange({
|
||||
...cursorHighlight,
|
||||
offsetYNorm: values[0],
|
||||
})
|
||||
<div
|
||||
className={
|
||||
cursorHighlight.enabled ? "" : "opacity-40 pointer-events-none"
|
||||
}
|
||||
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 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>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)}
|
||||
|
||||
@@ -168,6 +168,25 @@ 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(true);
|
||||
@@ -2039,6 +2058,7 @@ export default function VideoEditor() {
|
||||
borderRadius={borderRadius}
|
||||
padding={padding}
|
||||
cropRegion={cropRegion}
|
||||
cursorRecordingData={cursorRecordingData}
|
||||
trimRegions={trimRegions}
|
||||
speedRegions={speedRegions}
|
||||
annotationRegions={annotationOnlyRegions}
|
||||
@@ -2056,6 +2076,11 @@ export default function VideoEditor() {
|
||||
cursorTelemetry={cursorTelemetry}
|
||||
cursorHighlight={effectiveCursorHighlight}
|
||||
cursorClickTimestamps={cursorClickTimestamps}
|
||||
showCursor={effectiveShowCursor}
|
||||
cursorSize={cursorSize}
|
||||
cursorSmoothing={cursorSmoothing}
|
||||
cursorMotionBlur={cursorMotionBlur}
|
||||
cursorClickBounce={cursorClickBounce}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2201,6 +2226,21 @@ 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}
|
||||
hasCursorData={
|
||||
cursorTelemetry.length > 0 || hasNativeCursorRecordingData(cursorRecordingData)
|
||||
}
|
||||
showCursorSettings={showCursorSettings}
|
||||
showCursorHighlightSettings={isMac}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,12 +59,13 @@ import {
|
||||
DEFAULT_CURSOR_SIZE,
|
||||
DEFAULT_CURSOR_SMOOTHING,
|
||||
DEFAULT_ROTATION_3D,
|
||||
getZoomScale,
|
||||
isRotation3DIdentity,
|
||||
lerpRotation3D,
|
||||
rotation3DPerspective,
|
||||
type SpeedRegion,
|
||||
type TrimRegion,
|
||||
ZOOM_DEPTH_SCALES,
|
||||
type ZoomDepth,
|
||||
type ZoomFocus,
|
||||
type ZoomRegion,
|
||||
} from "./types";
|
||||
@@ -87,7 +88,12 @@ import {
|
||||
DEFAULT_CURSOR_HIGHLIGHT,
|
||||
drawCursorHighlightGraphics,
|
||||
} from "./videoPlayback/cursorHighlight";
|
||||
import { clampFocusToScale } from "./videoPlayback/focusUtils";
|
||||
import {
|
||||
DEFAULT_CURSOR_CONFIG,
|
||||
PixiCursorOverlay,
|
||||
preloadCursorAssets,
|
||||
} from "./videoPlayback/cursorRenderer";
|
||||
import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils";
|
||||
import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils";
|
||||
import { clamp01 } from "./videoPlayback/mathUtils";
|
||||
import { updateOverlayIndicator } from "./videoPlayback/overlayUtils";
|
||||
@@ -101,13 +107,6 @@ import {
|
||||
type MotionBlurState,
|
||||
} from "./videoPlayback/zoomTransform";
|
||||
|
||||
type BlurPreviewCanvasSource = {
|
||||
clientHeight?: number;
|
||||
clientWidth?: number;
|
||||
height: number;
|
||||
width: number;
|
||||
};
|
||||
|
||||
interface VideoPlaybackProps {
|
||||
videoPath: string;
|
||||
webcamVideoPath?: string;
|
||||
@@ -337,12 +336,131 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const videoReadyRafRef = useRef<number | null>(null);
|
||||
const smoothedAutoFocusRef = useRef<ZoomFocus | null>(null);
|
||||
const prevTargetProgressRef = useRef(0);
|
||||
const blurPreviewSnapshotRef = useRef<{
|
||||
bucket: number;
|
||||
canvas: BlurPreviewCanvasSource | null;
|
||||
height: number;
|
||||
width: number;
|
||||
}>({ bucket: -1, canvas: null, height: 0, width: 0 });
|
||||
const durationResolutionTimeoutRef = useRef<number | null>(null);
|
||||
const lastResolvedDurationRef = useRef<number | null>(null);
|
||||
const isResolvingDurationRef = useRef(false);
|
||||
const hasNativeCursorRecordingRef = useRef(false);
|
||||
const cursorRecordingDataRef = useRef(cursorRecordingData);
|
||||
const cropRegionRef = useRef(cropRegion);
|
||||
const nativeCursorSpriteRef = useRef<Sprite | null>(null);
|
||||
const nativeCursorTextureIdRef = useRef<string | null>(null);
|
||||
const nativeCursorImageRef = useRef<HTMLImageElement | null>(null);
|
||||
const nativeCursorImageIdRef = useRef<string | null>(null);
|
||||
const nativeCursorSmoothingStateRef = useRef(createNativeCursorSmoothingState());
|
||||
const nativeCursorMotionBlurStateRef = useRef(createNativeCursorMotionBlurState());
|
||||
|
||||
const hasNativeCursorRecording = useMemo(
|
||||
() => hasNativeCursorRecordingData(cursorRecordingData),
|
||||
[cursorRecordingData],
|
||||
);
|
||||
|
||||
const syncResolvedDuration = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
const resolvedDuration = getResolvedVideoDuration(video);
|
||||
if (!resolvedDuration) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedDuration = Math.round(resolvedDuration * 1000) / 1000;
|
||||
if (lastResolvedDurationRef.current !== normalizedDuration) {
|
||||
lastResolvedDurationRef.current = normalizedDuration;
|
||||
onDurationChange(normalizedDuration);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[onDurationChange],
|
||||
);
|
||||
|
||||
const forceResolveDuration = useCallback(
|
||||
(video: HTMLVideoElement) => {
|
||||
if (isResolvingDurationRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (video.readyState < HTMLMediaElement.HAVE_METADATA) {
|
||||
return;
|
||||
}
|
||||
|
||||
isResolvingDurationRef.current = true;
|
||||
const previousMuted = video.muted;
|
||||
|
||||
const finalize = () => {
|
||||
video.removeEventListener("durationchange", handleProgress);
|
||||
video.removeEventListener("timeupdate", handleProgress);
|
||||
video.removeEventListener("loadeddata", handleProgress);
|
||||
video.removeEventListener("ended", handleProgress);
|
||||
if (durationResolutionTimeoutRef.current) {
|
||||
clearTimeout(durationResolutionTimeoutRef.current);
|
||||
durationResolutionTimeoutRef.current = null;
|
||||
}
|
||||
video.muted = previousMuted;
|
||||
isResolvingDurationRef.current = false;
|
||||
};
|
||||
|
||||
const resolveCurrentDuration = () => {
|
||||
if (syncResolvedDuration(video)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const endedDuration = getEndedVideoDuration(video);
|
||||
if (endedDuration) {
|
||||
lastResolvedDurationRef.current = null;
|
||||
onDurationChange(Math.round(endedDuration * 1000) / 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
if (!resolveCurrentDuration()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
video.pause();
|
||||
video.currentTime = 0;
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
currentTimeRef.current = 0;
|
||||
finalize();
|
||||
};
|
||||
|
||||
video.addEventListener("durationchange", handleProgress);
|
||||
video.addEventListener("timeupdate", handleProgress);
|
||||
video.addEventListener("loadeddata", handleProgress);
|
||||
video.addEventListener("ended", handleProgress);
|
||||
durationResolutionTimeoutRef.current = window.setTimeout(() => {
|
||||
handleProgress();
|
||||
finalize();
|
||||
}, 1500);
|
||||
video.muted = true;
|
||||
|
||||
const playAttempt = video.play();
|
||||
if (playAttempt && typeof playAttempt.catch === "function") {
|
||||
playAttempt.catch(() => {
|
||||
try {
|
||||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||||
} catch {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
video.currentTime = Math.max(video.currentTime, 0.1);
|
||||
} catch {
|
||||
finalize();
|
||||
}
|
||||
},
|
||||
[onDurationChange, syncResolvedDuration],
|
||||
);
|
||||
|
||||
const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => {
|
||||
return clampFocusToStageUtil(focus, depth, stageSizeRef.current);
|
||||
}, []);
|
||||
|
||||
const updateOverlayForRegion = useCallback(
|
||||
(region: ZoomRegion | null, focusOverride?: ZoomFocus) => {
|
||||
@@ -524,7 +642,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
cx: clamp01(localX / stageWidth),
|
||||
cy: clamp01(localY / stageHeight),
|
||||
};
|
||||
const clampedFocus = clampFocusToScale(unclampedFocus, getZoomScale(region));
|
||||
const clampedFocus = clampFocusToStage(unclampedFocus, region.depth);
|
||||
|
||||
onZoomFocusChange(region.id, clampedFocus);
|
||||
updateOverlayForRegion({ ...region, focus: clampedFocus }, clampedFocus);
|
||||
@@ -1130,7 +1248,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
const shouldShowUnzoomedView = hasSelectedZoom && !isPlayingRef.current;
|
||||
|
||||
if (region && strength > 0 && !shouldShowUnzoomedView) {
|
||||
const zoomScale = blendedScale ?? getZoomScale(region);
|
||||
const zoomScale = blendedScale ?? ZOOM_DEPTH_SCALES[region.depth];
|
||||
const regionFocus = region.focus;
|
||||
|
||||
targetScaleFactor = zoomScale;
|
||||
@@ -1779,32 +1897,15 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
region: blurRegion,
|
||||
})),
|
||||
].sort((a, b) => a.region.zIndex - b.region.zIndex);
|
||||
const previewSnapshotBucket = Math.floor(currentTime * 10);
|
||||
const previewSnapshotCanvas =
|
||||
filteredBlurRegions.length > 0
|
||||
? (() => {
|
||||
const cached = blurPreviewSnapshotRef.current;
|
||||
if (
|
||||
cached.bucket === previewSnapshotBucket &&
|
||||
cached.width === overlaySize.width &&
|
||||
cached.height === overlaySize.height
|
||||
) {
|
||||
return cached.canvas;
|
||||
}
|
||||
|
||||
const app = appRef.current;
|
||||
if (!app?.renderer?.extract) return cached.canvas;
|
||||
if (!app?.renderer?.extract) return null;
|
||||
try {
|
||||
const canvas = app.renderer.extract.canvas(app.stage);
|
||||
blurPreviewSnapshotRef.current = {
|
||||
bucket: previewSnapshotBucket,
|
||||
canvas,
|
||||
height: overlaySize.height,
|
||||
width: overlaySize.width,
|
||||
};
|
||||
return canvas;
|
||||
return app.renderer.extract.canvas(app.stage);
|
||||
} catch {
|
||||
return cached.canvas;
|
||||
return null;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
@@ -1876,7 +1977,7 @@ const VideoPlayback = forwardRef<VideoPlaybackRef, VideoPlaybackProps>(
|
||||
: item.region.id === selectedAnnotationId
|
||||
}
|
||||
previewSourceCanvas={previewSnapshotCanvas}
|
||||
previewFrameVersion={previewSnapshotBucket}
|
||||
previewFrameVersion={Math.round(currentTime * 1000)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user