fix: restore native cursor wiring after upstream rebase

This commit is contained in:
EtienneLescot
2026-05-10 15:19:19 +02:00
parent 8137e816fd
commit 0720a6d802
6 changed files with 524 additions and 234 deletions
+21 -3
View File
@@ -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
View File
@@ -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.
});
}
}
-1
View File
@@ -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(() => {
+320 -190
View File
@@ -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>
+139 -38
View File
@@ -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)}
/>
));
})()}