diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index d82754d..6faaa4b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -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, + }); } diff --git a/electron/main.ts b/electron/main.ts index 1db4740..24d06ac 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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. + }); } } diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 071caa7..3b2bebf 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -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(() => { diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index ad74239..11e1b3c 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -309,6 +309,19 @@ interface SettingsPanelProps { onWebcamSizePresetChange?: (size: WebcamSizePreset) => void; onWebcamSizePresetCommit?: () => void; onSaveDiagnostic?: () => Promise; + 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("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 && ( -
+ {activePanelMode === "cursor" && showCursorSettings && hasCursorData && ( +
-
- {t("effects.cursorHighlight.title")} -
- -
-
- {(["dot", "ring"] as const).map((style) => ( - - ))} -
-
-
-
- {t("effects.cursorHighlight.size")} -
- - {cursorHighlight.sizePx}px - -
- - 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" +
Show Cursor
+
- {cursorHighlightSupportsClicks && ( -
-
- {t("effects.cursorHighlight.onlyOnClicks")} + {showCursor && ( +
+
+
+
Size
+ + {cursorSize.toFixed(1)} + +
+ 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" + /> +
+
+
+
+ Smoothing +
+ + {Math.round(cursorSmoothing * 100)}% + +
+ 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" + /> +
+
+
+
+ Motion Blur +
+ + {Math.round(cursorMotionBlur * 100)}% + +
+ 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" + /> +
+
+
+
+ Click Bounce +
+ + {cursorClickBounce.toFixed(1)} + +
+ 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" + /> +
+
+ )} +
+ )} + + {activePanelMode === "cursor" && + showCursorHighlightSettings && + cursorHighlight && + onCursorHighlightChange && ( +
+
+
+ {t("effects.cursorHighlight.title")}
- )} -
-
- {t("effects.cursorHighlight.color")} -
- - - - - + ))} +
+
+
+
+ {t("effects.cursorHighlight.size")} +
+ + {cursorHighlight.sizePx}px + +
+ + 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" + /> +
+ {cursorHighlightSupportsClicks && ( +
- +
+ {t("effects.cursorHighlight.onlyOnClicks")} +
+
-
-
-
- {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")} +
- - {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% - -
- - onCursorHighlightChange({ - ...cursorHighlight, - offsetXNorm: values[0], - }) + )} +
-
-
-
-
- {t("effects.cursorHighlight.offsetY")} + > +
+ {t("effects.cursorHighlight.color")}
- - {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% - + + + + + + + onCursorHighlightChange({ + ...cursorHighlight, + color, + }) + } + /> + +
- - onCursorHighlightChange({ - ...cursorHighlight, - offsetYNorm: values[0], - }) +
+ > +
+
+ {t("effects.cursorHighlight.offsetX")} +
+ + {(cursorHighlight.offsetXNorm * 100).toFixed(1)}% + +
+ + 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" + /> +
+
+
+
+ {t("effects.cursorHighlight.offsetY")} +
+ + {(cursorHighlight.offsetYNorm * 100).toFixed(1)}% + +
+ + 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" + /> +
-
- )} + )} )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index f3eb8d0..0525d85 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -168,6 +168,25 @@ export default function VideoEditor() { } | null>(null); const [isFullscreen, setIsFullscreen] = useState(false); const [showCloseConfirmDialog, setShowCloseConfirmDialog] = useState(false); + const playerContainerRef = useRef(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(() => { + 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} />
@@ -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} />
diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 368d465..10eb8a0 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -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( const videoReadyRafRef = useRef(null); const smoothedAutoFocusRef = useRef(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(null); + const lastResolvedDurationRef = useRef(null); + const isResolvingDurationRef = useRef(false); + const hasNativeCursorRecordingRef = useRef(false); + const cursorRecordingDataRef = useRef(cursorRecordingData); + const cropRegionRef = useRef(cropRegion); + const nativeCursorSpriteRef = useRef(null); + const nativeCursorTextureIdRef = useRef(null); + const nativeCursorImageRef = useRef(null); + const nativeCursorImageIdRef = useRef(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( 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( 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( 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( : item.region.id === selectedAnnotationId } previewSourceCanvas={previewSnapshotCanvas} - previewFrameVersion={previewSnapshotBucket} + previewFrameVersion={Math.round(currentTime * 1000)} /> )); })()}