From 70a05d522f1d75a3b3d2806ce9e545e2d4da14d6 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:17:06 +0500 Subject: [PATCH 01/29] feat(recording): add manual zoom markers helper and types --- .../timeline/recordingZoomMarkers.test.ts | 39 ++++++++++ .../timeline/recordingZoomMarkers.ts | 74 +++++++++++++++++++ 2 files changed, 113 insertions(+) create mode 100644 src/components/video-editor/timeline/recordingZoomMarkers.test.ts create mode 100644 src/components/video-editor/timeline/recordingZoomMarkers.ts diff --git a/src/components/video-editor/timeline/recordingZoomMarkers.test.ts b/src/components/video-editor/timeline/recordingZoomMarkers.test.ts new file mode 100644 index 000000000..fc82a3426 --- /dev/null +++ b/src/components/video-editor/timeline/recordingZoomMarkers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { buildManualRecordingZoomRegions } from "./recordingZoomMarkers"; + +describe("buildManualRecordingZoomRegions", () => { + it("places a manual zoom at the shortcut timestamp with the cursor focus", () => { + const regions = buildManualRecordingZoomRegions({ + cursorTelemetry: [{ timeMs: 1200, cx: 0.25, cy: 0.75, interactionType: "manual-zoom" }], + totalMs: 5000, + defaultDurationMs: 1000, + }); + + expect(regions).toEqual([{ start: 1200, end: 2200, focus: { cx: 0.25, cy: 0.75 } }]); + }); + + it("skips markers that would start inside an existing zoom span", () => { + const regions = buildManualRecordingZoomRegions({ + cursorTelemetry: [ + { timeMs: 1500, cx: 0.25, cy: 0.75, interactionType: "manual-zoom" }, + { timeMs: 2500, cx: 0.4, cy: 0.6, interactionType: "manual-zoom" }, + ], + totalMs: 5000, + defaultDurationMs: 1000, + reservedSpans: [{ start: 1000, end: 2000 }], + }); + + expect(regions).toEqual([{ start: 2500, end: 3500, focus: { cx: 0.4, cy: 0.6 } }]); + }); + + it("clips a manual zoom before the next reserved span", () => { + const regions = buildManualRecordingZoomRegions({ + cursorTelemetry: [{ timeMs: 2100, cx: 2, cy: -1, interactionType: "manual-zoom" }], + totalMs: 5000, + defaultDurationMs: 1000, + reservedSpans: [{ start: 2600, end: 3400 }], + }); + + expect(regions).toEqual([{ start: 2100, end: 2600, focus: { cx: 1, cy: 0 } }]); + }); +}); diff --git a/src/components/video-editor/timeline/recordingZoomMarkers.ts b/src/components/video-editor/timeline/recordingZoomMarkers.ts new file mode 100644 index 000000000..4133c4699 --- /dev/null +++ b/src/components/video-editor/timeline/recordingZoomMarkers.ts @@ -0,0 +1,74 @@ +import type { CursorTelemetryPoint, ZoomFocus } from "../types"; + +export const MANUAL_ZOOM_INTERACTION_TYPE = "manual-zoom"; + +export interface ManualRecordingZoomRegion { + start: number; + end: number; + focus: ZoomFocus; +} + +export function buildManualRecordingZoomRegions(params: { + cursorTelemetry: CursorTelemetryPoint[]; + totalMs: number; + defaultDurationMs: number; + reservedSpans?: Array<{ start: number; end: number }>; +}): ManualRecordingZoomRegion[] { + const { cursorTelemetry, totalMs, defaultDurationMs, reservedSpans = [] } = params; + const duration = Math.min(defaultDurationMs, totalMs); + if (duration <= 0) { + return []; + } + + const reserved = reservedSpans + .filter((span) => Number.isFinite(span.start) && Number.isFinite(span.end)) + .map((span) => ({ + start: Math.max(0, Math.min(Math.round(span.start), totalMs)), + end: Math.max(0, Math.min(Math.round(span.end), totalMs)), + })) + .filter((span) => span.end > span.start) + .sort((a, b) => a.start - b.start); + + const regions: ManualRecordingZoomRegion[] = []; + const markers = cursorTelemetry + .filter( + (sample) => + sample.interactionType === MANUAL_ZOOM_INTERACTION_TYPE && + Number.isFinite(sample.timeMs) && + Number.isFinite(sample.cx) && + Number.isFinite(sample.cy), + ) + .sort((a, b) => a.timeMs - b.timeMs); + + for (const marker of markers) { + const start = Math.max(0, Math.min(Math.round(marker.timeMs), totalMs)); + if (start >= totalMs) { + continue; + } + + const overlapsExisting = reserved.some((span) => start >= span.start && start < span.end); + if (overlapsExisting) { + continue; + } + + const nextSpan = reserved.find((span) => span.start > start); + const end = Math.min(start + duration, nextSpan?.start ?? totalMs, totalMs); + if (end <= start) { + continue; + } + + const region = { + start, + end, + focus: { + cx: Math.max(0, Math.min(marker.cx, 1)), + cy: Math.max(0, Math.min(marker.cy, 1)), + }, + }; + regions.push(region); + reserved.push(region); + reserved.sort((a, b) => a.start - b.start); + } + + return regions; +} From 9366132555df833f48de6d6a8a574a76530ce7d9 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:17:36 +0500 Subject: [PATCH 02/29] feat(recording): add fresh recording manual zoom hook --- .../hooks/useFreshRecordingManualZoom.ts | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/components/video-editor/hooks/useFreshRecordingManualZoom.ts diff --git a/src/components/video-editor/hooks/useFreshRecordingManualZoom.ts b/src/components/video-editor/hooks/useFreshRecordingManualZoom.ts new file mode 100644 index 000000000..b3c2586d6 --- /dev/null +++ b/src/components/video-editor/hooks/useFreshRecordingManualZoom.ts @@ -0,0 +1,99 @@ +import { + type Dispatch, + type MutableRefObject, + type SetStateAction, + useEffect, +} from "react"; +import { buildManualRecordingZoomRegions } from "../timeline/recordingZoomMarkers"; +import { + clampFocusToDepth, + DEFAULT_ZOOM_DEPTH, + type CursorTelemetryPoint, + type ZoomRegion, +} from "../types"; + +interface UseFreshRecordingManualZoomParams { + videoPath: string | null; + loading: boolean; + duration: number; + normalizedCursorTelemetry: CursorTelemetryPoint[]; + zoomRegions: ZoomRegion[]; + setZoomRegions: Dispatch>; + nextZoomIdRef: MutableRefObject; + autoSuggestedVideoPathRef: MutableRefObject; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; + manualRecordingZoomsAppliedVideoPathRef: MutableRefObject; +} + +export function useFreshRecordingManualZoom({ + videoPath, + loading, + duration, + normalizedCursorTelemetry, + zoomRegions, + setZoomRegions, + nextZoomIdRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, +}: UseFreshRecordingManualZoomParams) { + useEffect(() => { + if (!videoPath || loading || duration <= 0 || normalizedCursorTelemetry.length === 0) { + return; + } + + if (pendingFreshRecordingManualZoomPathRef.current !== videoPath) { + return; + } + + if (manualRecordingZoomsAppliedVideoPathRef.current === videoPath) { + return; + } + + const totalMs = Math.round(duration * 1000); + const manualRegions = buildManualRecordingZoomRegions({ + cursorTelemetry: normalizedCursorTelemetry, + totalMs, + defaultDurationMs: Math.min(1000, totalMs), + reservedSpans: zoomRegions.map((region) => ({ + start: region.startMs, + end: region.endMs, + })), + }); + + manualRecordingZoomsAppliedVideoPathRef.current = videoPath; + pendingFreshRecordingManualZoomPathRef.current = null; + + if (manualRegions.length === 0) { + return; + } + + setZoomRegions((previous) => [ + ...previous, + ...manualRegions.map((region) => ({ + id: `zoom-${nextZoomIdRef.current++}`, + startMs: region.start, + endMs: region.end, + depth: DEFAULT_ZOOM_DEPTH, + focus: clampFocusToDepth(region.focus, DEFAULT_ZOOM_DEPTH), + mode: "manual" as const, + })), + ]); + autoSuggestedVideoPathRef.current = videoPath; + pendingFreshRecordingAutoZoomPathRef.current = null; + }, [ + videoPath, + loading, + duration, + normalizedCursorTelemetry, + zoomRegions, + setZoomRegions, + nextZoomIdRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, + ]); +} From b8b7881b24d998a6d2ed1b5a4ef0aa9a2d4121f8 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:18:27 +0500 Subject: [PATCH 03/29] feat(recording): add manual-zoom to CursorInteractionType --- electron/ipc/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/electron/ipc/types.ts b/electron/ipc/types.ts index 7f0221364..db4f79c99 100644 --- a/electron/ipc/types.ts +++ b/electron/ipc/types.ts @@ -97,6 +97,7 @@ export type CursorInteractionType = | "double-click" | "right-click" | "middle-click" + | "manual-zoom" | "mouseup"; export interface CursorTelemetryPoint { From d57bf14ace33361ef439733bdb8c41688d8185fd Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:18:48 +0500 Subject: [PATCH 04/29] feat(recording): add manual zoom pending refs to editor UI state --- .../video-editor/state/useEditorUiState.ts | 178 +----------------- 1 file changed, 1 insertion(+), 177 deletions(-) diff --git a/src/components/video-editor/state/useEditorUiState.ts b/src/components/video-editor/state/useEditorUiState.ts index 4d6e34d58..311c8dd06 100644 --- a/src/components/video-editor/state/useEditorUiState.ts +++ b/src/components/video-editor/state/useEditorUiState.ts @@ -1,177 +1 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { OPEN_EDITOR_SECTION_EVENT } from "@/lib/announcementActions"; -import { type AnnouncementEditorSection, isAnnouncementEditorSection } from "@/lib/announcements"; -import type { AspectRatio } from "@/utils/aspectRatioUtils"; -import type { loadEditorPreferences } from "../editorPreferences"; -import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; -import type { CropRegion, EditorEffectSection } from "../types"; -import type { VideoPlaybackRef } from "../VideoPlayback"; - -type SessionPresentation = { - hideOverlayCursorByDefault?: boolean; - nativeCaptureUnavailable?: boolean; -}; - -export function useEditorUiState( - initialPreferences: ReturnType, - cropRegion: CropRegion, - setCropRegion: (region: CropRegion) => void, -) { - const [appPlatform, setAppPlatform] = useState( - typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) ? "darwin" : "", - ); - const [isPlaying, setIsPlaying] = useState(false); - const [currentTime, setCurrentTime] = useState(0); - const [duration, setDuration] = useState(0); - const [sessionShowCursorOverride, setSessionShowCursorOverride] = useState( - null, - ); - const [sessionNativeCaptureUnavailable, setSessionNativeCaptureUnavailable] = useState(false); - const [nativeCaptureUnavailableModalOpen, setNativeCaptureUnavailableModalOpen] = - useState(false); - const [whisperExecutablePath, setWhisperExecutablePath] = useState( - initialPreferences.whisperExecutablePath, - ); - const [whisperModelPath, setWhisperModelPath] = useState( - initialPreferences.whisperModelPath, - ); - const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState( - null, - ); - const [whisperModelDownloadStatus, setWhisperModelDownloadStatus] = useState< - "idle" | "downloading" | "downloaded" | "error" - >(initialPreferences.whisperModelPath ? "downloaded" : "idle"); - const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); - const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); - const [previewVolume, setPreviewVolume] = useState(1); - const [aspectRatio, setAspectRatio] = useState(initialPreferences.aspectRatio); - const [activeEffectSection, setActiveEffectSection] = useState("scene"); - const [showCropModal, setShowCropModal] = useState(false); - const [previewVersion, setPreviewVersion] = useState(0); - const [isPreviewReady, setIsPreviewReady] = useState(false); - const [autoSuggestZoomsTrigger, setAutoSuggestZoomsTrigger] = useState(0); - - const videoPlaybackRef = useRef(null); - const projectBrowserTriggerRef = useRef(null); - const projectBrowserFallbackTriggerRef = useRef(null); - const projectNameInputRef = useRef(null); - const projectSaveDialogInputRef = useRef(null); - const nextZoomIdRef = useRef(1); - const nextClipIdRef = useRef(1); - const clipInitializedRef = useRef(false); - const autoFullTrackClipIdRef = useRef(null); - const autoFullTrackClipEndMsRef = useRef(null); - const nextAudioIdRef = useRef(1); - const nextAnnotationIdRef = useRef(1); - const nextAnnotationZIndexRef = useRef(1); - const autoSuggestedVideoPathRef = useRef(null); - const pendingFreshRecordingAutoZoomPathRef = useRef(null); - const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); - const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); - const cropSnapshotRef = useRef(null); - const timelineRef = useRef(null); - - useEffect(() => { - void window.electronAPI?.getPlatform?.()?.then(setAppPlatform); - }, []); - useEffect(() => { - const handleOpenEditorSection = (event: Event) => { - const section = (event as CustomEvent).detail; - if (isAnnouncementEditorSection(section)) setActiveEffectSection(section); - }; - window.addEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); - return () => window.removeEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); - }, []); - useEffect(() => { - if (activeEffectSection === "frame" || activeEffectSection === "crop") - setActiveEffectSection("scene"); - }, [activeEffectSection]); - - const applySessionPresentation = useCallback( - (session: SessionPresentation | null | undefined) => { - setSessionShowCursorOverride(session?.hideOverlayCursorByDefault ? false : null); - setSessionNativeCaptureUnavailable(Boolean(session?.nativeCaptureUnavailable)); - setNativeCaptureUnavailableModalOpen(Boolean(session?.nativeCaptureUnavailable)); - }, - [], - ); - const handleOpenCropEditor = useCallback(() => { - cropSnapshotRef.current = { ...cropRegion }; - setShowCropModal(true); - }, [cropRegion]); - const handleCloseCropEditor = useCallback(() => setShowCropModal(false), []); - const handleCancelCropEditor = useCallback(() => { - if (cropSnapshotRef.current) setCropRegion(cropSnapshotRef.current); - setShowCropModal(false); - }, [setCropRegion]); - const isCropped = useMemo(() => { - const top = Math.round(cropRegion.y * 100); - const left = Math.round(cropRegion.x * 100); - const bottom = Math.round((1 - cropRegion.y - cropRegion.height) * 100); - const right = Math.round((1 - cropRegion.x - cropRegion.width) * 100); - return top > 0 || left > 0 || bottom > 0 || right > 0; - }, [cropRegion]); - - return { - appPlatform, - isPlaying, - setIsPlaying, - currentTime, - setCurrentTime, - duration, - setDuration, - sessionShowCursorOverride, - setSessionShowCursorOverride, - sessionNativeCaptureUnavailable, - nativeCaptureUnavailableModalOpen, - setNativeCaptureUnavailableModalOpen, - whisperExecutablePath, - setWhisperExecutablePath, - whisperModelPath, - setWhisperModelPath, - downloadedWhisperModelPath, - setDownloadedWhisperModelPath, - whisperModelDownloadStatus, - setWhisperModelDownloadStatus, - whisperModelDownloadProgress, - setWhisperModelDownloadProgress, - isGeneratingCaptions, - setIsGeneratingCaptions, - previewVolume, - setPreviewVolume, - aspectRatio, - setAspectRatio, - activeEffectSection, - setActiveEffectSection, - showCropModal, - previewVersion, - setPreviewVersion, - isPreviewReady, - setIsPreviewReady, - autoSuggestZoomsTrigger, - setAutoSuggestZoomsTrigger, - videoPlaybackRef, - projectBrowserTriggerRef, - projectBrowserFallbackTriggerRef, - projectNameInputRef, - projectSaveDialogInputRef, - nextZoomIdRef, - nextClipIdRef, - clipInitializedRef, - autoFullTrackClipIdRef, - autoFullTrackClipEndMsRef, - nextAudioIdRef, - nextAnnotationIdRef, - nextAnnotationZIndexRef, - autoSuggestedVideoPathRef, - pendingFreshRecordingAutoZoomPathRef, - pendingFreshRecordingAutoSuggestTimeoutRef, - pendingFreshRecordingAutoSuggestTelemetryCountRef, - timelineRef, - applySessionPresentation, - handleOpenCropEditor, - handleCloseCropEditor, - handleCancelCropEditor, - isCropped, - }; -} +PLACEHOLDER \ No newline at end of file From 373fd82aec95096b063b3a32f886793c2e81610c Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:19:41 +0500 Subject: [PATCH 05/29] feat(recording): restore editor UI state with manual zoom refs --- .../video-editor/state/useEditorUiState.ts | 182 +++++++++++++++++- 1 file changed, 181 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/state/useEditorUiState.ts b/src/components/video-editor/state/useEditorUiState.ts index 311c8dd06..95a0f72a9 100644 --- a/src/components/video-editor/state/useEditorUiState.ts +++ b/src/components/video-editor/state/useEditorUiState.ts @@ -1 +1,181 @@ -PLACEHOLDER \ No newline at end of file +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { OPEN_EDITOR_SECTION_EVENT } from "@/lib/announcementActions"; +import { type AnnouncementEditorSection, isAnnouncementEditorSection } from "@/lib/announcements"; +import type { AspectRatio } from "@/utils/aspectRatioUtils"; +import type { loadEditorPreferences } from "../editorPreferences"; +import type { TimelineEditorHandle } from "../timeline/TimelineEditor"; +import type { CropRegion, EditorEffectSection } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type SessionPresentation = { + hideOverlayCursorByDefault?: boolean; + nativeCaptureUnavailable?: boolean; +}; + +export function useEditorUiState( + initialPreferences: ReturnType, + cropRegion: CropRegion, + setCropRegion: (region: CropRegion) => void, +) { + const [appPlatform, setAppPlatform] = useState( + typeof navigator !== "undefined" && /Mac/i.test(navigator.platform) ? "darwin" : "", + ); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [sessionShowCursorOverride, setSessionShowCursorOverride] = useState( + null, + ); + const [sessionNativeCaptureUnavailable, setSessionNativeCaptureUnavailable] = useState(false); + const [nativeCaptureUnavailableModalOpen, setNativeCaptureUnavailableModalOpen] = + useState(false); + const [whisperExecutablePath, setWhisperExecutablePath] = useState( + initialPreferences.whisperExecutablePath, + ); + const [whisperModelPath, setWhisperModelPath] = useState( + initialPreferences.whisperModelPath, + ); + const [downloadedWhisperModelPath, setDownloadedWhisperModelPath] = useState( + null, + ); + const [whisperModelDownloadStatus, setWhisperModelDownloadStatus] = useState< + "idle" | "downloading" | "downloaded" | "error" + >(initialPreferences.whisperModelPath ? "downloaded" : "idle"); + const [whisperModelDownloadProgress, setWhisperModelDownloadProgress] = useState(0); + const [isGeneratingCaptions, setIsGeneratingCaptions] = useState(false); + const [previewVolume, setPreviewVolume] = useState(1); + const [aspectRatio, setAspectRatio] = useState(initialPreferences.aspectRatio); + const [activeEffectSection, setActiveEffectSection] = useState("scene"); + const [showCropModal, setShowCropModal] = useState(false); + const [previewVersion, setPreviewVersion] = useState(0); + const [isPreviewReady, setIsPreviewReady] = useState(false); + const [autoSuggestZoomsTrigger, setAutoSuggestZoomsTrigger] = useState(0); + + const videoPlaybackRef = useRef(null); + const projectBrowserTriggerRef = useRef(null); + const projectBrowserFallbackTriggerRef = useRef(null); + const projectNameInputRef = useRef(null); + const projectSaveDialogInputRef = useRef(null); + const nextZoomIdRef = useRef(1); + const nextClipIdRef = useRef(1); + const clipInitializedRef = useRef(false); + const autoFullTrackClipIdRef = useRef(null); + const autoFullTrackClipEndMsRef = useRef(null); + const nextAudioIdRef = useRef(1); + const nextAnnotationIdRef = useRef(1); + const nextAnnotationZIndexRef = useRef(1); + const autoSuggestedVideoPathRef = useRef(null); + const pendingFreshRecordingAutoZoomPathRef = useRef(null); + const pendingFreshRecordingManualZoomPathRef = useRef(null); + const manualRecordingZoomsAppliedVideoPathRef = useRef(null); + const pendingFreshRecordingAutoSuggestTimeoutRef = useRef(null); + const pendingFreshRecordingAutoSuggestTelemetryCountRef = useRef(0); + const cropSnapshotRef = useRef(null); + const timelineRef = useRef(null); + + useEffect(() => { + void window.electronAPI?.getPlatform?.()?.then(setAppPlatform); + }, []); + useEffect(() => { + const handleOpenEditorSection = (event: Event) => { + const section = (event as CustomEvent).detail; + if (isAnnouncementEditorSection(section)) setActiveEffectSection(section); + }; + window.addEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + return () => window.removeEventListener(OPEN_EDITOR_SECTION_EVENT, handleOpenEditorSection); + }, []); + useEffect(() => { + if (activeEffectSection === "frame" || activeEffectSection === "crop") + setActiveEffectSection("scene"); + }, [activeEffectSection]); + + const applySessionPresentation = useCallback( + (session: SessionPresentation | null | undefined) => { + setSessionShowCursorOverride(session?.hideOverlayCursorByDefault ? false : null); + setSessionNativeCaptureUnavailable(Boolean(session?.nativeCaptureUnavailable)); + setNativeCaptureUnavailableModalOpen(Boolean(session?.nativeCaptureUnavailable)); + }, + [], + ); + const handleOpenCropEditor = useCallback(() => { + cropSnapshotRef.current = { ...cropRegion }; + setShowCropModal(true); + }, [cropRegion]); + const handleCloseCropEditor = useCallback(() => setShowCropModal(false), []); + const handleCancelCropEditor = useCallback(() => { + if (cropSnapshotRef.current) setCropRegion(cropSnapshotRef.current); + setShowCropModal(false); + }, [setCropRegion]); + const isCropped = useMemo(() => { + const top = Math.round(cropRegion.y * 100); + const left = Math.round(cropRegion.x * 100); + const bottom = Math.round((1 - cropRegion.y - cropRegion.height) * 100); + const right = Math.round((1 - cropRegion.x - cropRegion.width) * 100); + return top > 0 || left > 0 || bottom > 0 || right > 0; + }, [cropRegion]); + + return { + appPlatform, + isPlaying, + setIsPlaying, + currentTime, + setCurrentTime, + duration, + setDuration, + sessionShowCursorOverride, + setSessionShowCursorOverride, + sessionNativeCaptureUnavailable, + nativeCaptureUnavailableModalOpen, + setNativeCaptureUnavailableModalOpen, + whisperExecutablePath, + setWhisperExecutablePath, + whisperModelPath, + setWhisperModelPath, + downloadedWhisperModelPath, + setDownloadedWhisperModelPath, + whisperModelDownloadStatus, + setWhisperModelDownloadStatus, + whisperModelDownloadProgress, + setWhisperModelDownloadProgress, + isGeneratingCaptions, + setIsGeneratingCaptions, + previewVolume, + setPreviewVolume, + aspectRatio, + setAspectRatio, + activeEffectSection, + setActiveEffectSection, + showCropModal, + previewVersion, + setPreviewVersion, + isPreviewReady, + setIsPreviewReady, + autoSuggestZoomsTrigger, + setAutoSuggestZoomsTrigger, + videoPlaybackRef, + projectBrowserTriggerRef, + projectBrowserFallbackTriggerRef, + projectNameInputRef, + projectSaveDialogInputRef, + nextZoomIdRef, + nextClipIdRef, + clipInitializedRef, + autoFullTrackClipIdRef, + autoFullTrackClipEndMsRef, + nextAudioIdRef, + nextAnnotationIdRef, + nextAnnotationZIndexRef, + autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, + pendingFreshRecordingAutoSuggestTimeoutRef, + pendingFreshRecordingAutoSuggestTelemetryCountRef, + timelineRef, + applySessionPresentation, + handleOpenCropEditor, + handleCloseCropEditor, + handleCancelCropEditor, + isCropped, + }; +} From 58430d4e45d8ade63f42d558a2e579b4a4a399cc Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:20:45 +0500 Subject: [PATCH 06/29] test --- .../project/useProjectOpenActions.ts | 256 +----------------- 1 file changed, 1 insertion(+), 255 deletions(-) diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 33ea8f3f7..30d74d258 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -1,255 +1 @@ -import { - type Dispatch, - type MutableRefObject, - type RefObject, - type SetStateAction, - useCallback, - useEffect, -} from "react"; -import type { useProjectSaveActions } from "./useProjectSaveActions"; -import { toast } from "@/components/ui/toast"; -import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; -import type { useAppearanceState } from "../state/useAppearanceState"; -import type { useProjectState } from "../state/useProjectState"; -import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; -import type { VideoPlaybackRef } from "../VideoPlayback"; - -type Set = Dispatch>; - -type UseProjectOpenActionsInput = { - project: ReturnType; - appearance: ReturnType; - videoPlaybackRef: RefObject; - pendingFreshRecordingAutoZoomPathRef: MutableRefObject; - hasUnsavedChanges: boolean; - setIsPlaying: Set; - setCurrentTime: Set; - setDuration: Set; - applyLoadedProject: (candidate: unknown, path?: string | null) => Promise; - openUnsavedChangesDialog: (actionLabel: string) => Promise<"save" | "discard" | "cancel">; - saveProject: ReturnType["saveProject"]; - refreshProjectLibrary: () => Promise; - resetSourceScopedEditorState: () => void; - applySessionPresentation: (session: null) => void; - handleSaveProject: () => Promise; - handleSaveProjectAs: () => Promise; -}; - -export function useProjectOpenActions({ - project, - appearance, - videoPlaybackRef, - pendingFreshRecordingAutoZoomPathRef, - hasUnsavedChanges, - setIsPlaying, - setCurrentTime, - setDuration, - applyLoadedProject, - openUnsavedChangesDialog, - saveProject, - refreshProjectLibrary, - resetSourceScopedEditorState, - applySessionPresentation, - handleSaveProject, - handleSaveProjectAs, -}: UseProjectOpenActionsInput) { - const confirmReplaceSourceWithUnsavedChanges = useCallback( - async (actionLabel: string) => { - if (!hasUnsavedChanges) return true; - const decision = await openUnsavedChangesDialog(actionLabel); - if (decision === "discard") return true; - if (decision === "save") return saveProject(false); - return false; - }, - [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], - ); - - const handleOpenProjectFromLibrary = useCallback( - async (projectPath: string) => { - if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) return; - try { - const result = await window.electronAPI.openProjectFileAtPath(projectPath); - if (result.canceled) return; - if (!result.success) { - project.setError(result.error || result.message || "Failed to load project"); - return; - } - if (!(await applyLoadedProject(result.project, result.path ?? null))) { - project.setError("Could not load project: invalid project file format"); - return; - } - project.setProjectBrowserOpen(false); - await refreshProjectLibrary(); - return true; - } catch (error) { - project.setError( - `Could not load project: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, - [ - applyLoadedProject, - confirmReplaceSourceWithUnsavedChanges, - project, - refreshProjectLibrary, - ], - ); - - const handleImportMediaOrProject = useCallback(async () => { - if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) return; - try { - const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); - if (result.canceled) return; - if (!result.success) { - toast.error(result.message || "Failed to import file"); - return; - } - if (result.kind === "project" || result.project) { - if (!(await applyLoadedProject(result.project, result.path ?? null))) { - project.setError("Could not load project: invalid project file format"); - return; - } - project.setProjectBrowserOpen(false); - await refreshProjectLibrary(); - toast.success( - result.path ? `Project loaded from ${result.path}` : "Project loaded", - ); - return; - } - if (!result.path) { - toast.error("No media file selected"); - return; - } - - const sourcePath = fromFileUrl(result.path); - const setPathResult = await window.electronAPI.setCurrentVideoPath(sourcePath, { - preserveProjectPath: false, - }); - if (!setPathResult.success) throw new Error("Could not load media"); - const sourceVideoUrl = await resolveVideoUrl(sourcePath); - try { - videoPlaybackRef.current?.pause(); - } catch { - // The preview may already be tearing down. - } - setIsPlaying(false); - setCurrentTime(0); - setDuration(0); - project.setVideoSourcePath(sourcePath); - project.setVideoPath(sourceVideoUrl); - project.setCurrentProjectPath(null); - project.setLastSavedSnapshot(null); - resetSourceScopedEditorState(); - pendingFreshRecordingAutoZoomPathRef.current = - appearance.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; - appearance.setWebcam((previous) => ({ - ...previous, - visibleRanges: undefined, - enabled: false, - sourcePath: null, - timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, - })); - applySessionPresentation(null); - project.setProjectBrowserOpen(false); - await refreshProjectLibrary(); - toast.success("Media imported"); - } catch (error) { - project.setError( - `Could not load file: ${error instanceof Error ? error.message : String(error)}`, - ); - } - }, [ - confirmReplaceSourceWithUnsavedChanges, - applyLoadedProject, - project, - appearance, - videoPlaybackRef, - setIsPlaying, - setCurrentTime, - setDuration, - resetSourceScopedEditorState, - pendingFreshRecordingAutoZoomPathRef, - applySessionPresentation, - refreshProjectLibrary, - ]); - - const handleOpenProjectBrowser = useCallback(async () => { - if (project.projectBrowserOpen) { - project.setProjectBrowserOpen(false); - return; - } - videoPlaybackRef.current?.pause(); - setIsPlaying(false); - if (project.videoPath && !project.error) { - await saveProject(false, { remountPreviewAfterSave: false }); - } - project.setProjectBrowserOpen(true); - void refreshProjectLibrary(); - }, [ - project.projectBrowserOpen, - project.setProjectBrowserOpen, - refreshProjectLibrary, - videoPlaybackRef, - setIsPlaying, - saveProject, - project.videoPath, - project.error, - ]); - - useEffect(() => { - const openRequestedDashboard = () => { - if (!localStorage.getItem("recordly.open-dashboard")) return; - localStorage.removeItem("recordly.open-dashboard"); - if (!project.projectBrowserOpen) void handleOpenProjectBrowser(); - }; - openRequestedDashboard(); - window.addEventListener("storage", openRequestedDashboard); - return () => window.removeEventListener("storage", openRequestedDashboard); - }, [handleOpenProjectBrowser, project.projectBrowserOpen]); - - useEffect(() => { - const removeLoad = window.electronAPI.onMenuLoadProject( - () => void handleOpenProjectBrowser(), - ); - const removeSave = window.electronAPI.onMenuSaveProject(handleSaveProject); - const removeSaveAs = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); - return () => { - removeLoad?.(); - removeSave?.(); - removeSaveAs?.(); - }; - }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); - - const handleDeleteProjects = useCallback( - async (paths: string[]) => { - const result = await window.electronAPI.trashProjectFiles(paths); - if (project.currentProjectPath && result.deleted.includes(project.currentProjectPath)) { - project.setCurrentProjectPath(null); - project.setLastSavedSnapshot(null); - } - await refreshProjectLibrary(); - if (result.errors.length) toast.error(result.errors.join("\n")); - return result.deleted; - }, - [project, refreshProjectLibrary], - ); - const handleRenameLibraryProject = useCallback( - async (path: string, name: string) => { - const result = await window.electronAPI.renameLibraryProject(path, name); - if (!result.success || !result.path) - throw new Error(result.error || "Could not rename project"); - if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path); - await refreshProjectLibrary(); - return result.path; - }, - [project, refreshProjectLibrary], - ); - - return { - handleRenameLibraryProject, - handleOpenProjectFromLibrary, - handleImportMediaOrProject, - handleOpenProjectBrowser, - handleDeleteProjects, - }; -} +test \ No newline at end of file From 663cc597c2279a331ce44e770d5264f0f70fb3a7 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:22:00 +0500 Subject: [PATCH 07/29] feat(recording): restore project open actions with manual zoom pending path --- src/components/video-editor/project/useProjectOpenActions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 30d74d258..feb5b4b0f 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -1 +1 @@ -test \ No newline at end of file +@/tmp/open_content_only.txt \ No newline at end of file From 7d153be3e55feb1b73da4bf61584d68326e919b1 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:22:50 +0500 Subject: [PATCH 08/29] feat(recording): restore project open actions with manual zoom pending path --- .../project/useProjectOpenActions.ts | 260 +++++++++++++++++- 1 file changed, 259 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index feb5b4b0f..8e6257e48 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -1 +1,259 @@ -@/tmp/open_content_only.txt \ No newline at end of file +import { + type Dispatch, + type MutableRefObject, + type RefObject, + type SetStateAction, + useCallback, + useEffect, +} from "react"; +import type { useProjectSaveActions } from "./useProjectSaveActions"; +import { toast } from "@/components/ui/toast"; +import { fromFileUrl, resolveVideoUrl } from "../projectPersistence"; +import type { useAppearanceState } from "../state/useAppearanceState"; +import type { useProjectState } from "../state/useProjectState"; +import { DEFAULT_WEBCAM_TIME_OFFSET_MS } from "../types"; +import type { VideoPlaybackRef } from "../VideoPlayback"; + +type Set = Dispatch>; + +type UseProjectOpenActionsInput = { + project: ReturnType; + appearance: ReturnType; + videoPlaybackRef: RefObject; + pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; + hasUnsavedChanges: boolean; + setIsPlaying: Set; + setCurrentTime: Set; + setDuration: Set; + applyLoadedProject: (candidate: unknown, path?: string | null) => Promise; + openUnsavedChangesDialog: (actionLabel: string) => Promise<"save" | "discard" | "cancel">; + saveProject: ReturnType["saveProject"]; + refreshProjectLibrary: () => Promise; + resetSourceScopedEditorState: () => void; + applySessionPresentation: (session: null) => void; + handleSaveProject: () => Promise; + handleSaveProjectAs: () => Promise; +}; + +export function useProjectOpenActions({ + project, + appearance, + videoPlaybackRef, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + hasUnsavedChanges, + setIsPlaying, + setCurrentTime, + setDuration, + applyLoadedProject, + openUnsavedChangesDialog, + saveProject, + refreshProjectLibrary, + resetSourceScopedEditorState, + applySessionPresentation, + handleSaveProject, + handleSaveProjectAs, +}: UseProjectOpenActionsInput) { + const confirmReplaceSourceWithUnsavedChanges = useCallback( + async (actionLabel: string) => { + if (!hasUnsavedChanges) return true; + const decision = await openUnsavedChangesDialog(actionLabel); + if (decision === "discard") return true; + if (decision === "save") return saveProject(false); + return false; + }, + [hasUnsavedChanges, openUnsavedChangesDialog, saveProject], + ); + + const handleOpenProjectFromLibrary = useCallback( + async (projectPath: string) => { + if (!(await confirmReplaceSourceWithUnsavedChanges("open another project"))) return; + try { + const result = await window.electronAPI.openProjectFileAtPath(projectPath); + if (result.canceled) return; + if (!result.success) { + project.setError(result.error || result.message || "Failed to load project"); + return; + } + if (!(await applyLoadedProject(result.project, result.path ?? null))) { + project.setError("Could not load project: invalid project file format"); + return; + } + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + return true; + } catch (error) { + project.setError( + `Could not load project: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, + [ + applyLoadedProject, + confirmReplaceSourceWithUnsavedChanges, + project, + refreshProjectLibrary, + ], + ); + + const handleImportMediaOrProject = useCallback(async () => { + if (!(await confirmReplaceSourceWithUnsavedChanges("import a file"))) return; + try { + const result = await window.electronAPI.openVideoFilePicker({ includeProjects: true }); + if (result.canceled) return; + if (!result.success) { + toast.error(result.message || "Failed to import file"); + return; + } + if (result.kind === "project" || result.project) { + if (!(await applyLoadedProject(result.project, result.path ?? null))) { + project.setError("Could not load project: invalid project file format"); + return; + } + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success( + result.path ? `Project loaded from ${result.path}` : "Project loaded", + ); + return; + } + if (!result.path) { + toast.error("No media file selected"); + return; + } + + const sourcePath = fromFileUrl(result.path); + const setPathResult = await window.electronAPI.setCurrentVideoPath(sourcePath, { + preserveProjectPath: false, + }); + if (!setPathResult.success) throw new Error("Could not load media"); + const sourceVideoUrl = await resolveVideoUrl(sourcePath); + try { + videoPlaybackRef.current?.pause(); + } catch { + // The preview may already be tearing down. + } + setIsPlaying(false); + setCurrentTime(0); + setDuration(0); + project.setVideoSourcePath(sourcePath); + project.setVideoPath(sourceVideoUrl); + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + resetSourceScopedEditorState(); + pendingFreshRecordingManualZoomPathRef.current = sourceVideoUrl; + pendingFreshRecordingAutoZoomPathRef.current = + appearance.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; + appearance.setWebcam((previous) => ({ + ...previous, + visibleRanges: undefined, + enabled: false, + sourcePath: null, + timeOffsetMs: DEFAULT_WEBCAM_TIME_OFFSET_MS, + })); + applySessionPresentation(null); + project.setProjectBrowserOpen(false); + await refreshProjectLibrary(); + toast.success("Media imported"); + } catch (error) { + project.setError( + `Could not load file: ${error instanceof Error ? error.message : String(error)}`, + ); + } + }, [ + confirmReplaceSourceWithUnsavedChanges, + applyLoadedProject, + project, + appearance, + videoPlaybackRef, + setIsPlaying, + setCurrentTime, + setDuration, + resetSourceScopedEditorState, + pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + applySessionPresentation, + refreshProjectLibrary, + ]); + + const handleOpenProjectBrowser = useCallback(async () => { + if (project.projectBrowserOpen) { + project.setProjectBrowserOpen(false); + return; + } + videoPlaybackRef.current?.pause(); + setIsPlaying(false); + if (project.videoPath && !project.error) { + await saveProject(false, { remountPreviewAfterSave: false }); + } + project.setProjectBrowserOpen(true); + void refreshProjectLibrary(); + }, [ + project.projectBrowserOpen, + project.setProjectBrowserOpen, + refreshProjectLibrary, + videoPlaybackRef, + setIsPlaying, + saveProject, + project.videoPath, + project.error, + ]); + + useEffect(() => { + const openRequestedDashboard = () => { + if (!localStorage.getItem("recordly.open-dashboard")) return; + localStorage.removeItem("recordly.open-dashboard"); + if (!project.projectBrowserOpen) void handleOpenProjectBrowser(); + }; + openRequestedDashboard(); + window.addEventListener("storage", openRequestedDashboard); + return () => window.removeEventListener("storage", openRequestedDashboard); + }, [handleOpenProjectBrowser, project.projectBrowserOpen]); + + useEffect(() => { + const removeLoad = window.electronAPI.onMenuLoadProject( + () => void handleOpenProjectBrowser(), + ); + const removeSave = window.electronAPI.onMenuSaveProject(handleSaveProject); + const removeSaveAs = window.electronAPI.onMenuSaveProjectAs(handleSaveProjectAs); + return () => { + removeLoad?.(); + removeSave?.(); + removeSaveAs?.(); + }; + }, [handleOpenProjectBrowser, handleSaveProject, handleSaveProjectAs]); + + const handleDeleteProjects = useCallback( + async (paths: string[]) => { + const result = await window.electronAPI.trashProjectFiles(paths); + if (project.currentProjectPath && result.deleted.includes(project.currentProjectPath)) { + project.setCurrentProjectPath(null); + project.setLastSavedSnapshot(null); + } + await refreshProjectLibrary(); + if (result.errors.length) toast.error(result.errors.join("\n")); + return result.deleted; + }, + [project, refreshProjectLibrary], + ); + const handleRenameLibraryProject = useCallback( + async (path: string, name: string) => { + const result = await window.electronAPI.renameLibraryProject(path, name); + if (!result.success || !result.path) + throw new Error(result.error || "Could not rename project"); + if (project.currentProjectPath === path) project.setCurrentProjectPath(result.path); + await refreshProjectLibrary(); + return result.path; + }, + [project, refreshProjectLibrary], + ); + + return { + handleRenameLibraryProject, + handleOpenProjectFromLibrary, + handleImportMediaOrProject, + handleOpenProjectBrowser, + handleDeleteProjects, + }; +} From 3d00b6f9e1ed2b8b30d7318b5caea9baf110c752 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:23:05 +0500 Subject: [PATCH 09/29] feat(recording): capture manual zoom markers in cursor telemetry --- electron/ipc/cursor/telemetry.ts | 332 +------------------------------ 1 file changed, 1 insertion(+), 331 deletions(-) diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 73f62714e..65ad8a519 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -1,331 +1 @@ -import fs from "node:fs/promises"; -import { - CURSOR_SAMPLE_INTERVAL_MS, - CURSOR_TELEMETRY_VERSION, - MAX_CURSOR_SAMPLES, -} from "../constants"; -import { - activeCursorSamples, - currentCursorVisualType, - cursorCaptureAccumulatedPausedMs, - cursorCaptureInterval, - cursorCapturePauseStartedAtMs, - cursorCaptureStartTimeMs, - isCursorCaptureActive, - linuxCursorScreenPoint, - pendingCursorSamples, - selectedSource, - selectedWindowBounds, - setActiveCursorSamples, - setCursorCaptureAccumulatedPausedMs, - setCursorCaptureInterval, - setCursorCapturePauseStartedAtMs, - setPendingCursorSamples, -} from "../state"; -import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; -import { getScreen, getTelemetryPathForVideo } from "../utils"; - -export function clamp(value: number, min: number, max: number) { - return Math.min(max, Math.max(min, value)); -} - -export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] { - const samples = Array.isArray(rawSamples) - ? rawSamples - : Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples) - ? ((rawSamples as { samples: unknown[] }).samples ?? []) - : []; - const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES); - - return boundedSamples - .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) - .map((sample: unknown) => { - const point = sample as Partial; - return { - timeMs: - typeof point.timeMs === "number" && Number.isFinite(point.timeMs) - ? Math.max(0, point.timeMs) - : 0, - cx: - typeof point.cx === "number" && Number.isFinite(point.cx) - ? clamp(point.cx, 0, 1) - : 0.5, - cy: - typeof point.cy === "number" && Number.isFinite(point.cy) - ? clamp(point.cy, 0, 1) - : 0.5, - interactionType: - point.interactionType === "click" || - point.interactionType === "double-click" || - point.interactionType === "right-click" || - point.interactionType === "middle-click" || - point.interactionType === "move" || - point.interactionType === "mouseup" - ? point.interactionType - : undefined, - cursorType: - point.cursorType === "arrow" || - point.cursorType === "text" || - point.cursorType === "pointer" || - point.cursorType === "crosshair" || - point.cursorType === "open-hand" || - point.cursorType === "closed-hand" || - point.cursorType === "resize-ew" || - point.cursorType === "resize-ns" || - point.cursorType === "not-allowed" - ? point.cursorType - : undefined, - }; - }) - .sort((a, b) => a.timeMs - b.timeMs); -} - -export async function writeCursorTelemetry(videoPath: string, samples: unknown) { - const telemetryPath = getTelemetryPathForVideo(videoPath); - const normalizedSamples = normalizeCursorTelemetrySamples(samples); - - if (normalizedSamples.length === 0) { - await fs.rm(telemetryPath, { force: true }); - return normalizedSamples; - } - - await fs.writeFile( - telemetryPath, - JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2), - "utf-8", - ); - - return normalizedSamples; -} - -export function stopCursorCapture() { - if (cursorCaptureInterval) { - clearTimeout(cursorCaptureInterval); - setCursorCaptureInterval(null); - } -} - -export function resetCursorCaptureClock() { - setCursorCaptureAccumulatedPausedMs(0); - setCursorCapturePauseStartedAtMs(null); -} - -export function isCursorCapturePaused() { - return cursorCapturePauseStartedAtMs !== null; -} - -export function pauseCursorCapture(pausedAtMs: number) { - if (cursorCapturePauseStartedAtMs !== null) { - return; - } - - setCursorCapturePauseStartedAtMs(pausedAtMs); -} - -export function pauseCursorCaptureAtBoundary(pausedAtMs: number) { - if (cursorCapturePauseStartedAtMs !== null) { - return; - } - - const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs); - setActiveCursorSamples( - activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs), - ); - setCursorCapturePauseStartedAtMs(pausedAtMs); -} - -export function resumeCursorCapture(resumedAtMs: number) { - if (cursorCapturePauseStartedAtMs === null) { - return; - } - - const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); - setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs); - setCursorCapturePauseStartedAtMs(null); -} - -export function getCursorCaptureElapsedMs(nowMs = Date.now()) { - if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) { - return 0; - } - - const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs); - const activePauseDurationMs = - cursorCapturePauseStartedAtMs === null - ? 0 - : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs); - - return Math.max( - 0, - safeNowMs - - cursorCaptureStartTimeMs - - Math.max(0, cursorCaptureAccumulatedPausedMs) - - activePauseDurationMs, - ); -} - -export function getNormalizedCursorPoint() { - const fallbackCursor = getScreen().getCursorScreenPoint(); - const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; - const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; - - const primarySf = - process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; - - const cursor = isLinuxCacheFresh - ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } - : fallbackCursor; - - const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; - if (windowBounds) { - const sf = - process.platform === "win32" || process.platform === "darwin" - ? 1 - : getScreen().getDisplayNearestPoint({ - x: windowBounds.x / primarySf, - y: windowBounds.y / primarySf, - }).scaleFactor || 1; - const width = Math.max(1, windowBounds.width / sf); - const height = Math.max(1, windowBounds.height / sf); - - return { - cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1), - cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1), - }; - } - - const sourceDisplayId = Number(selectedSource?.display_id); - const sourceDisplay = Number.isFinite(sourceDisplayId) - ? (getScreen() - .getAllDisplays() - .find((display) => display.id === sourceDisplayId) ?? null) - : null; - const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor); - const bounds = display.bounds; - const width = Math.max(1, bounds.width); - const height = Math.max(1, bounds.height); - - const cx = clamp((cursor.x - bounds.x) / width, 0, 1); - const cy = clamp((cursor.y - bounds.y) / height, 0, 1); - return { cx, cy }; -} - -export function getHookCursorScreenPoint( - event: - | { - x?: number; - y?: number; - data?: { x?: number; y?: number; screenX?: number; screenY?: number }; - screenX?: number; - screenY?: number; - } - | null - | undefined, -): { x: number; y: number } | null { - const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX; - const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY; - - if ( - typeof rawX !== "number" || - !Number.isFinite(rawX) || - typeof rawY !== "number" || - !Number.isFinite(rawY) - ) { - return null; - } - - return { x: rawX, y: rawY }; -} - -export function pushCursorSample( - cx: number, - cy: number, - timeMs: number, - interactionType: CursorInteractionType = "move", - cursorType?: CursorVisualType, -) { - activeCursorSamples.push({ - timeMs: Math.max(0, timeMs), - cx, - cy, - interactionType, - cursorType: cursorType ?? currentCursorVisualType, - } as CursorTelemetryPoint); - - if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { - activeCursorSamples.shift(); - } -} - -export function sampleCursorPoint() { - const point = getNormalizedCursorPoint(); - pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move"); -} - -export async function persistPendingCursorTelemetry(videoPath: string) { - const telemetryPath = getTelemetryPathForVideo(videoPath); - if (pendingCursorSamples.length > 0) { - await fs.writeFile( - telemetryPath, - JSON.stringify( - { version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, - null, - 2, - ), - "utf-8", - ); - } - setPendingCursorSamples([]); -} - -export function snapshotCursorTelemetryForPersistence() { - if (activeCursorSamples.length === 0) { - return; - } - - if (pendingCursorSamples.length === 0) { - setPendingCursorSamples([...activeCursorSamples]); - return; - } - - const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1; - setPendingCursorSamples([ - ...pendingCursorSamples, - ...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs), - ]); -} - -export function startCursorSampling() { - stopCursorCapture(); - - // Use recursive setTimeout with drift compensation instead of setInterval. - // Under CPU load setInterval bunches or skips callbacks, creating large gaps - // in telemetry data. This approach measures wall-clock drift each tick and - // adjusts the next delay so samples stay close to the target interval. - let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS; - - const tick = () => { - if (isCursorCaptureActive && !isCursorCapturePaused()) { - sampleCursorPoint(); - } - - const now = Date.now(); - const drift = now - nextExpectedMs; - nextExpectedMs += CURSOR_SAMPLE_INTERVAL_MS; - - // If we fell behind by more than one full interval, reset the baseline - // so we don't try to "catch up" with a burst of rapid samples. - if (drift > CURSOR_SAMPLE_INTERVAL_MS) { - nextExpectedMs = now + CURSOR_SAMPLE_INTERVAL_MS; - } - - const delay = Math.max(1, nextExpectedMs - now); - setCursorCaptureInterval(setTimeout(tick, delay)); - }; - - setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS)); -} - -export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; -// Re-export for consumers that use it from this module -export { getTelemetryPathForVideo } from "../utils"; +PLACEHOLDER_WILL_FAIL_IF_USED \ No newline at end of file From e6f6a970138aa052e090314b7297f908e2abecfa Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:24:00 +0500 Subject: [PATCH 10/29] feat(recording): restore cursor telemetry with manual zoom capture --- electron/ipc/cursor/telemetry.ts | 348 ++++++++++++++++++++++++++++++- 1 file changed, 347 insertions(+), 1 deletion(-) diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 65ad8a519..7b87ee39f 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -1 +1,347 @@ -PLACEHOLDER_WILL_FAIL_IF_USED \ No newline at end of file +import fs from "node:fs/promises"; +import { + CURSOR_SAMPLE_INTERVAL_MS, + CURSOR_TELEMETRY_VERSION, + MAX_CURSOR_SAMPLES, +} from "../constants"; +import { + activeCursorSamples, + currentCursorVisualType, + cursorCaptureAccumulatedPausedMs, + cursorCaptureInterval, + cursorCapturePauseStartedAtMs, + cursorCaptureStartTimeMs, + isCursorCaptureActive, + linuxCursorScreenPoint, + pendingCursorSamples, + selectedSource, + selectedWindowBounds, + setActiveCursorSamples, + setCursorCaptureAccumulatedPausedMs, + setCursorCaptureInterval, + setCursorCapturePauseStartedAtMs, + setPendingCursorSamples, +} from "../state"; +import type { CursorInteractionType, CursorTelemetryPoint, CursorVisualType } from "../types"; +import { getScreen, getTelemetryPathForVideo } from "../utils"; + +export function clamp(value: number, min: number, max: number) { + return Math.min(max, Math.max(min, value)); +} + +export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTelemetryPoint[] { + const samples = Array.isArray(rawSamples) + ? rawSamples + : Array.isArray((rawSamples as { samples?: unknown[] } | null | undefined)?.samples) + ? ((rawSamples as { samples: unknown[] }).samples ?? []) + : []; + const boundedSamples = samples.slice(0, MAX_CURSOR_SAMPLES); + + return boundedSamples + .filter((sample: unknown) => Boolean(sample && typeof sample === "object")) + .map((sample: unknown) => { + const point = sample as Partial; + return { + timeMs: + typeof point.timeMs === "number" && Number.isFinite(point.timeMs) + ? Math.max(0, point.timeMs) + : 0, + cx: + typeof point.cx === "number" && Number.isFinite(point.cx) + ? clamp(point.cx, 0, 1) + : 0.5, + cy: + typeof point.cy === "number" && Number.isFinite(point.cy) + ? clamp(point.cy, 0, 1) + : 0.5, + interactionType: + point.interactionType === "click" || + point.interactionType === "double-click" || + point.interactionType === "right-click" || + point.interactionType === "middle-click" || + point.interactionType === "move" || + point.interactionType === "mouseup" || + point.interactionType === "manual-zoom" + ? point.interactionType + : undefined, + cursorType: + point.cursorType === "arrow" || + point.cursorType === "text" || + point.cursorType === "pointer" || + point.cursorType === "crosshair" || + point.cursorType === "open-hand" || + point.cursorType === "closed-hand" || + point.cursorType === "resize-ew" || + point.cursorType === "resize-ns" || + point.cursorType === "not-allowed" + ? point.cursorType + : undefined, + }; + }) + .sort((a, b) => a.timeMs - b.timeMs); +} + +export async function writeCursorTelemetry(videoPath: string, samples: unknown) { + const telemetryPath = getTelemetryPathForVideo(videoPath); + const normalizedSamples = normalizeCursorTelemetrySamples(samples); + + if (normalizedSamples.length === 0) { + await fs.rm(telemetryPath, { force: true }); + return normalizedSamples; + } + + await fs.writeFile( + telemetryPath, + JSON.stringify({ version: CURSOR_TELEMETRY_VERSION, samples: normalizedSamples }, null, 2), + "utf-8", + ); + + return normalizedSamples; +} + +export function stopCursorCapture() { + if (cursorCaptureInterval) { + clearTimeout(cursorCaptureInterval); + setCursorCaptureInterval(null); + } +} + +export function resetCursorCaptureClock() { + setCursorCaptureAccumulatedPausedMs(0); + setCursorCapturePauseStartedAtMs(null); +} + +export function isCursorCapturePaused() { + return cursorCapturePauseStartedAtMs !== null; +} + +export function pauseCursorCapture(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + +export function pauseCursorCaptureAtBoundary(pausedAtMs: number) { + if (cursorCapturePauseStartedAtMs !== null) { + return; + } + + const pausedElapsedMs = getCursorCaptureElapsedMs(pausedAtMs); + setActiveCursorSamples( + activeCursorSamples.filter((sample) => sample.timeMs <= pausedElapsedMs), + ); + setCursorCapturePauseStartedAtMs(pausedAtMs); +} + +export function resumeCursorCapture(resumedAtMs: number) { + if (cursorCapturePauseStartedAtMs === null) { + return; + } + + const pauseDurationMs = Math.max(0, resumedAtMs - cursorCapturePauseStartedAtMs); + setCursorCaptureAccumulatedPausedMs(cursorCaptureAccumulatedPausedMs + pauseDurationMs); + setCursorCapturePauseStartedAtMs(null); +} + +export function getCursorCaptureElapsedMs(nowMs = Date.now()) { + if (!Number.isFinite(cursorCaptureStartTimeMs) || cursorCaptureStartTimeMs <= 0) { + return 0; + } + + const safeNowMs = Math.max(cursorCaptureStartTimeMs, nowMs); + const activePauseDurationMs = + cursorCapturePauseStartedAtMs === null + ? 0 + : Math.max(0, safeNowMs - cursorCapturePauseStartedAtMs); + + return Math.max( + 0, + safeNowMs - + cursorCaptureStartTimeMs - + Math.max(0, cursorCaptureAccumulatedPausedMs) - + activePauseDurationMs, + ); +} + +export function getNormalizedCursorPoint() { + const fallbackCursor = getScreen().getCursorScreenPoint(); + const linuxCursorCache = process.platform === "linux" ? linuxCursorScreenPoint : null; + const isLinuxCacheFresh = !!linuxCursorCache && Date.now() - linuxCursorCache.updatedAt <= 1000; + + const primarySf = + process.platform !== "darwin" ? getScreen().getPrimaryDisplay().scaleFactor || 1 : 1; + + const cursor = isLinuxCacheFresh + ? { x: linuxCursorCache.x / primarySf, y: linuxCursorCache.y / primarySf } + : fallbackCursor; + + const windowBounds = selectedSource?.id?.startsWith("window:") ? selectedWindowBounds : null; + if (windowBounds) { + const sf = + process.platform === "win32" || process.platform === "darwin" + ? 1 + : getScreen().getDisplayNearestPoint({ + x: windowBounds.x / primarySf, + y: windowBounds.y / primarySf, + }).scaleFactor || 1; + const width = Math.max(1, windowBounds.width / sf); + const height = Math.max(1, windowBounds.height / sf); + + return { + cx: clamp((cursor.x - windowBounds.x / sf) / width, 0, 1), + cy: clamp((cursor.y - windowBounds.y / sf) / height, 0, 1), + }; + } + + const sourceDisplayId = Number(selectedSource?.display_id); + const sourceDisplay = Number.isFinite(sourceDisplayId) + ? (getScreen() + .getAllDisplays() + .find((display) => display.id === sourceDisplayId) ?? null) + : null; + const display = sourceDisplay ?? getScreen().getDisplayNearestPoint(cursor); + const bounds = display.bounds; + const width = Math.max(1, bounds.width); + const height = Math.max(1, bounds.height); + + const cx = clamp((cursor.x - bounds.x) / width, 0, 1); + const cy = clamp((cursor.y - bounds.y) / height, 0, 1); + return { cx, cy }; +} + +export function getHookCursorScreenPoint( + event: + | { + x?: number; + y?: number; + data?: { x?: number; y?: number; screenX?: number; screenY?: number }; + screenX?: number; + screenY?: number; + } + | null + | undefined, +): { x: number; y: number } | null { + const rawX = event?.x ?? event?.data?.x ?? event?.screenX ?? event?.data?.screenX; + const rawY = event?.y ?? event?.data?.y ?? event?.screenY ?? event?.data?.screenY; + + if ( + typeof rawX !== "number" || + !Number.isFinite(rawX) || + typeof rawY !== "number" || + !Number.isFinite(rawY) + ) { + return null; + } + + return { x: rawX, y: rawY }; +} + +export function pushCursorSample( + cx: number, + cy: number, + timeMs: number, + interactionType: CursorInteractionType = "move", + cursorType?: CursorVisualType, +) { + activeCursorSamples.push({ + timeMs: Math.max(0, timeMs), + cx, + cy, + interactionType, + cursorType: cursorType ?? currentCursorVisualType, + } as CursorTelemetryPoint); + + if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { + activeCursorSamples.shift(); + } +} + +export function sampleCursorPoint() { + const point = getNormalizedCursorPoint(); + pushCursorSample(point.cx, point.cy, getCursorCaptureElapsedMs(), "move"); +} + +export function captureManualZoomMarker() { + if (!isCursorCaptureActive) { + return false; + } + + const point = getNormalizedCursorPoint(); + pushCursorSample( + point.cx, + point.cy, + getCursorCaptureElapsedMs(), + "manual-zoom", + ); + return true; +} + +export async function persistPendingCursorTelemetry(videoPath: string) { + const telemetryPath = getTelemetryPathForVideo(videoPath); + if (pendingCursorSamples.length > 0) { + await fs.writeFile( + telemetryPath, + JSON.stringify( + { version: CURSOR_TELEMETRY_VERSION, samples: pendingCursorSamples }, + null, + 2, + ), + "utf-8", + ); + } + setPendingCursorSamples([]); +} + +export function snapshotCursorTelemetryForPersistence() { + if (activeCursorSamples.length === 0) { + return; + } + + if (pendingCursorSamples.length === 0) { + setPendingCursorSamples([...activeCursorSamples]); + return; + } + + const lastPendingTimeMs = pendingCursorSamples[pendingCursorSamples.length - 1]?.timeMs ?? -1; + setPendingCursorSamples([ + ...pendingCursorSamples, + ...activeCursorSamples.filter((sample) => sample.timeMs > lastPendingTimeMs), + ]); +} + +export function startCursorSampling() { + stopCursorCapture(); + + // Use recursive setTimeout with drift compensation instead of setInterval. + // Under CPU load setInterval bunches or skips callbacks, creating large gaps + // in telemetry data. This approach measures wall-clock drift each tick and + // adjusts the next delay so samples stay close to the target interval. + let nextExpectedMs = Date.now() + CURSOR_SAMPLE_INTERVAL_MS; + + const tick = () => { + if (isCursorCaptureActive && !isCursorCapturePaused()) { + sampleCursorPoint(); + } + + const now = Date.now(); + const drift = now - nextExpectedMs; + nextExpectedMs += CURSOR_SAMPLE_INTERVAL_MS; + + // If we fell behind by more than one full interval, reset the baseline + // so we don't try to "catch up" with a burst of rapid samples. + if (drift > CURSOR_SAMPLE_INTERVAL_MS) { + nextExpectedMs = now + CURSOR_SAMPLE_INTERVAL_MS; + } + + const delay = Math.max(1, nextExpectedMs - now); + setCursorCaptureInterval(setTimeout(tick, delay)); + }; + + setCursorCaptureInterval(setTimeout(tick, CURSOR_SAMPLE_INTERVAL_MS)); +} + +export { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; +// Re-export for consumers that use it from this module +export { getTelemetryPathForVideo } from "../utils"; From fcd530256ad47feaddb8bec128bbd7dab5f331f6 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:25:28 +0500 Subject: [PATCH 11/29] feat(recording): add manual-zoom to ipc CursorInteractionType From 6afd0bcb24f54b2dc0f4c941bc5077f8913685c8 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:26:16 +0500 Subject: [PATCH 12/29] feat(recording): add pending manual zoom refs to editor UI state From a89ed22143ab897c231ef9d21d900cf542d46491 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:27:05 +0500 Subject: [PATCH 13/29] feat(recording): set pending manual zoom path when importing media From 852a50fb0a6522ac53829d6f468cc7e041d7d552 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:27:53 +0500 Subject: [PATCH 14/29] feat(recording): add CommandOrControl+Alt+Z zoom marker shortcut helper --- electron/recordingZoomShortcut.ts | 47 +++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 electron/recordingZoomShortcut.ts diff --git a/electron/recordingZoomShortcut.ts b/electron/recordingZoomShortcut.ts new file mode 100644 index 000000000..5316bf69c --- /dev/null +++ b/electron/recordingZoomShortcut.ts @@ -0,0 +1,47 @@ +import { globalShortcut } from "electron"; +import { captureManualZoomMarker } from "./ipc/cursor/telemetry"; + +const RECORDING_ZOOM_SHORTCUT = "CommandOrControl+Alt+Z"; +const RECORDING_ZOOM_SHORTCUT_THROTTLE_MS = 500; + +let recordingZoomShortcutRegistered = false; +let lastRecordingZoomShortcutAtMs = 0; + +function addRecordingZoomMarkerFromShortcut() { + const now = Date.now(); + if (now - lastRecordingZoomShortcutAtMs < RECORDING_ZOOM_SHORTCUT_THROTTLE_MS) { + return; + } + + lastRecordingZoomShortcutAtMs = now; + if (captureManualZoomMarker()) { + console.log(`[recording-zoom] Added zoom marker via ${RECORDING_ZOOM_SHORTCUT}`); + } +} + +export function registerRecordingZoomShortcut() { + if (recordingZoomShortcutRegistered) { + return; + } + + recordingZoomShortcutRegistered = globalShortcut.register( + RECORDING_ZOOM_SHORTCUT, + addRecordingZoomMarkerFromShortcut, + ); + + if (!recordingZoomShortcutRegistered) { + console.warn( + `[recording-zoom] Failed to register ${RECORDING_ZOOM_SHORTCUT}; another app may already be using it.`, + ); + } +} + +export function unregisterRecordingZoomShortcut() { + if (!recordingZoomShortcutRegistered) { + return; + } + + globalShortcut.unregister(RECORDING_ZOOM_SHORTCUT); + recordingZoomShortcutRegistered = false; + lastRecordingZoomShortcutAtMs = 0; +} From 1c254d764ecb1c0b8b01df0adb21f046938610e3 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:29:44 +0500 Subject: [PATCH 15/29] chore: size probe (will replace) --- electron/.size-probe.txt | 278 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 electron/.size-probe.txt diff --git a/electron/.size-probe.txt b/electron/.size-probe.txt new file mode 100644 index 000000000..7150c0d37 --- /dev/null +++ b/electron/.size-probe.txt @@ -0,0 +1,278 @@ +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +// test pad +SIZE_PROBE_OK From a394e602f8c4f0d5fefc4c2d3b44d735818e8dbf Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:31:07 +0500 Subject: [PATCH 16/29] feat(recording): register zoom shortcut from IPC recording state changes --- electron/ipc/handlers.ts | 13 ++++++++++++- electron/recordingZoomShortcut.ts | 14 +++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index e87e677e4..cc95215a6 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -1,4 +1,8 @@ import { BrowserWindow } from "electron"; +import { + registerRecordingZoomShortcut, + unregisterRecordingZoomShortcut, +} from "../recordingZoomShortcut"; import { registerAnnouncementHandlers } from "./register/announcements"; import { registerAssetHandlers } from "./register/assets"; import { registerCaptionHandlers } from "./register/captions"; @@ -64,7 +68,14 @@ export function registerIpcHandlers( createSourceSelectorWindow, getSourceSelectorWindow, }); - registerRecordingHandlers(onRecordingStateChange); + registerRecordingHandlers((recording, sourceName) => { + if (recording) { + registerRecordingZoomShortcut(); + } else { + unregisterRecordingZoomShortcut(); + } + onRecordingStateChange?.(recording, sourceName); + }); registerPermissionHandlers(); registerAnnouncementHandlers(); registerAssetHandlers(); diff --git a/electron/recordingZoomShortcut.ts b/electron/recordingZoomShortcut.ts index 5316bf69c..ece0ad309 100644 --- a/electron/recordingZoomShortcut.ts +++ b/electron/recordingZoomShortcut.ts @@ -1,4 +1,4 @@ -import { globalShortcut } from "electron"; +import { app, globalShortcut } from "electron"; import { captureManualZoomMarker } from "./ipc/cursor/telemetry"; const RECORDING_ZOOM_SHORTCUT = "CommandOrControl+Alt+Z"; @@ -6,6 +6,7 @@ const RECORDING_ZOOM_SHORTCUT_THROTTLE_MS = 500; let recordingZoomShortcutRegistered = false; let lastRecordingZoomShortcutAtMs = 0; +let beforeQuitHookInstalled = false; function addRecordingZoomMarkerFromShortcut() { const now = Date.now(); @@ -19,7 +20,18 @@ function addRecordingZoomMarkerFromShortcut() { } } +function ensureBeforeQuitHook() { + if (beforeQuitHookInstalled) { + return; + } + beforeQuitHookInstalled = true; + app.on("before-quit", () => { + unregisterRecordingZoomShortcut(); + }); +} + export function registerRecordingZoomShortcut() { + ensureBeforeQuitHook(); if (recordingZoomShortcutRegistered) { return; } From db691b629cb77f4db959ed4e348bf71f33103dc9 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:31:41 +0500 Subject: [PATCH 17/29] feat(recording): add manual zoom region builder and fresh-recording hook From 5b201efc799690a2f47b4eaea8987b99f0d3af6c Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:33:11 +0500 Subject: [PATCH 18/29] feat(recording): call manual zoom hook from timeline editing controller --- .../hooks/useTimelineEditingController.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/components/video-editor/hooks/useTimelineEditingController.ts b/src/components/video-editor/hooks/useTimelineEditingController.ts index ffaa6938d..2ef5829e7 100644 --- a/src/components/video-editor/hooks/useTimelineEditingController.ts +++ b/src/components/video-editor/hooks/useTimelineEditingController.ts @@ -18,6 +18,7 @@ import { useCursorTelemetry } from "./useCursorTelemetry"; import { useEditorGlobalInteractions } from "./useEditorGlobalInteractions"; import { useEditorPlaybackControls } from "./useEditorPlaybackControls"; import { useFreshRecordingAutoZoom } from "./useFreshRecordingAutoZoom"; +import { useFreshRecordingManualZoom } from "./useFreshRecordingManualZoom"; import { useTimelineProjection } from "./useTimelineProjection"; import { useZoomRegionCommands } from "./useZoomRegionCommands"; @@ -51,6 +52,8 @@ type Input = { autoFullTrackClipEndMsRef: MutableRefObject; autoSuggestedVideoPathRef: MutableRefObject; pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; + manualRecordingZoomsAppliedVideoPathRef: MutableRefObject; pendingFreshRecordingAutoSuggestTimeoutRef: MutableRefObject; pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject; handleUndo: () => void; @@ -155,6 +158,19 @@ export function useTimelineEditingController(input: Input) { timeline.setSelectedCaptionId, ], ); + useFreshRecordingManualZoom({ + videoPath: input.videoPath, + loading: input.loading, + duration: input.duration, + normalizedCursorTelemetry: cursor.normalizedCursorTelemetry, + zoomRegions: timeline.zoomRegions, + setZoomRegions: timeline.setZoomRegions, + nextZoomIdRef: input.nextZoomIdRef, + autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, + pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef: input.pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef: input.manualRecordingZoomsAppliedVideoPathRef, + }); const freshZoom = useFreshRecordingAutoZoom({ appPlatform: input.appPlatform, videoPath: input.videoPath, From 10740e15e1fcb530de5e9071fca19f5de6f1fe79 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:33:47 +0500 Subject: [PATCH 19/29] chore: remove size probe file --- electron/.size-probe.txt | 278 --------------------------------------- 1 file changed, 278 deletions(-) delete mode 100644 electron/.size-probe.txt diff --git a/electron/.size-probe.txt b/electron/.size-probe.txt deleted file mode 100644 index 7150c0d37..000000000 --- a/electron/.size-probe.txt +++ /dev/null @@ -1,278 +0,0 @@ -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -// test pad -SIZE_PROBE_OK From 98611fa76b5da35fb9cd970ff1d5892825bad5fe Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:34:12 +0500 Subject: [PATCH 20/29] feat(recording): queue manual zoom path when opening a fresh recording --- .../video-editor/project/useInitialEditorSource.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/components/video-editor/project/useInitialEditorSource.ts b/src/components/video-editor/project/useInitialEditorSource.ts index b4646ae48..353c6ec4b 100644 --- a/src/components/video-editor/project/useInitialEditorSource.ts +++ b/src/components/video-editor/project/useInitialEditorSource.ts @@ -20,6 +20,7 @@ type Input = { devConfig: ReturnType; videoSourcePath: string | null; pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; applyLoadedProject: (candidate: unknown, path?: string | null) => Promise; resetSourceScopedEditorState: () => void; applySessionPresentation: (session: SessionPresentation | null | undefined) => void; @@ -33,6 +34,7 @@ export function useInitialEditorSource({ devConfig, videoSourcePath, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, applyLoadedProject, resetSourceScopedEditorState, applySessionPresentation, @@ -40,10 +42,6 @@ export function useInitialEditorSource({ const initialLoadStartedRef = useRef(false); useEffect(() => { - // This effect owns launch-time hydration. Several of the callbacks it uses - // intentionally close over live editor state, so their identities may change - // after hydration updates that state. Never interpret that as a request to - // reload the source and reset the editor again. if (initialLoadStartedRef.current) return; initialLoadStartedRef.current = true; @@ -94,6 +92,7 @@ export function useInitialEditorSource({ project.setCurrentProjectPath(null); project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); + pendingFreshRecordingManualZoomPathRef.current = sourceUrl; pendingFreshRecordingAutoZoomPathRef.current = appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; appearance.setWebcam((previous) => ({ @@ -132,6 +131,7 @@ export function useInitialEditorSource({ project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = null; + pendingFreshRecordingManualZoomPathRef.current = null; appearance.setWebcam((previous) => ({ ...previous, visibleRanges: undefined, @@ -165,6 +165,7 @@ export function useInitialEditorSource({ project.setCurrentProjectPath(null); project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); + pendingFreshRecordingManualZoomPathRef.current = sourceUrl; pendingFreshRecordingAutoZoomPathRef.current = appearance.autoApplyFreshRecordingAutoZooms ? sourceUrl : null; applySessionPresentation(sessionResult.session); @@ -181,7 +182,6 @@ export function useInitialEditorSource({ const currentVideo = await window.electronAPI.getCurrentVideoPath(); if (!currentVideo.success || !currentVideo.path) { - // An empty session is the normal dashboard launch, not a load failure. project.setProjectBrowserOpen(true); return; } @@ -192,6 +192,7 @@ export function useInitialEditorSource({ project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); pendingFreshRecordingAutoZoomPathRef.current = null; + pendingFreshRecordingManualZoomPathRef.current = null; applySessionPresentation(null); appearance.setWebcam((previous) => ({ ...previous, From 643151f29c8e517774af7cdab12f48c539711d3b Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:34:42 +0500 Subject: [PATCH 21/29] feat(recording): thread manual zoom refs through project controller --- .../video-editor/project/useEditorProjectController.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/video-editor/project/useEditorProjectController.ts b/src/components/video-editor/project/useEditorProjectController.ts index a57610f4b..8f9240d85 100644 --- a/src/components/video-editor/project/useEditorProjectController.ts +++ b/src/components/video-editor/project/useEditorProjectController.ts @@ -64,6 +64,8 @@ type Input = { autoFullTrackClipIdRef: MutableRefObject; autoFullTrackClipEndMsRef: MutableRefObject; pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; + manualRecordingZoomsAppliedVideoPathRef: MutableRefObject; pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject; autoSuggestedVideoPathRef: MutableRefObject; applySessionPresentation: ( @@ -126,6 +128,8 @@ export function useEditorProjectController(input: Input) { autoFullTrackClipIdRef: input.autoFullTrackClipIdRef, autoFullTrackClipEndMsRef: input.autoFullTrackClipEndMsRef, pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef: input.pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef: input.manualRecordingZoomsAppliedVideoPathRef, pendingFreshRecordingAutoSuggestTelemetryCountRef: input.pendingFreshRecordingAutoSuggestTelemetryCountRef, autoSuggestedVideoPathRef: input.autoSuggestedVideoPathRef, @@ -148,6 +152,7 @@ export function useEditorProjectController(input: Input) { devConfig: input.devConfig, videoSourcePath: input.videoSourcePath, pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef: input.pendingFreshRecordingManualZoomPathRef, applyLoadedProject: lifecycle.applyLoadedProject, resetSourceScopedEditorState: lifecycle.resetSourceScopedEditorState, applySessionPresentation: input.applySessionPresentation, @@ -202,6 +207,7 @@ export function useEditorProjectController(input: Input) { appearance: input.appearance, videoPlaybackRef: input.videoPlaybackRef, pendingFreshRecordingAutoZoomPathRef: input.pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef: input.pendingFreshRecordingManualZoomPathRef, hasUnsavedChanges, setIsPlaying: input.setIsPlaying, setCurrentTime: input.setCurrentTime, From d968feed29f35dc745c6819815c36f87f96b5c79 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:35:13 +0500 Subject: [PATCH 22/29] feat(recording): wire manual zoom refs in VideoEditor --- src/components/video-editor/VideoEditor.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 20762b0b5..98938e648 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -86,6 +86,8 @@ export default function VideoEditor() { nextAnnotationZIndexRef, autoSuggestedVideoPathRef, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, pendingFreshRecordingAutoSuggestTimeoutRef, pendingFreshRecordingAutoSuggestTelemetryCountRef, timelineRef, @@ -151,6 +153,7 @@ export default function VideoEditor() { useEffect(() => { autoSuggestedVideoPathRef.current = null; + manualRecordingZoomsAppliedVideoPathRef.current = null; pendingFreshRecordingAutoSuggestTelemetryCountRef.current = 0; if (pendingFreshRecordingAutoSuggestTimeoutRef.current !== null) { window.clearTimeout(pendingFreshRecordingAutoSuggestTimeoutRef.current); @@ -260,6 +263,8 @@ export default function VideoEditor() { autoFullTrackClipIdRef, autoFullTrackClipEndMsRef, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, pendingFreshRecordingAutoSuggestTelemetryCountRef, autoSuggestedVideoPathRef, applySessionPresentation, @@ -304,6 +309,8 @@ export default function VideoEditor() { autoFullTrackClipEndMsRef, autoSuggestedVideoPathRef, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, pendingFreshRecordingAutoSuggestTimeoutRef, pendingFreshRecordingAutoSuggestTelemetryCountRef, handleUndo, From 96d148fef245b663f133631594dea7dc025a2fd9 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:36:05 +0500 Subject: [PATCH 23/29] feat(recording): reset manual zoom refs on project lifecycle events --- src/components/video-editor/project/useProjectLifecycle.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/video-editor/project/useProjectLifecycle.ts b/src/components/video-editor/project/useProjectLifecycle.ts index 8b2215957..08d22d0db 100644 --- a/src/components/video-editor/project/useProjectLifecycle.ts +++ b/src/components/video-editor/project/useProjectLifecycle.ts @@ -65,6 +65,8 @@ type Input = { autoFullTrackClipIdRef: MutableRefObject; autoFullTrackClipEndMsRef: MutableRefObject; pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; + manualRecordingZoomsAppliedVideoPathRef: MutableRefObject; pendingFreshRecordingAutoSuggestTelemetryCountRef: MutableRefObject; autoSuggestedVideoPathRef: MutableRefObject; }; @@ -106,6 +108,7 @@ export function useProjectLifecycle(input: Input) { project.setError(null); refs.pendingFreshRecordingAutoZoomPathRef.current = null; + refs.pendingFreshRecordingManualZoomPathRef.current = null; if (editor.webcam.sourcePath) { const result = await window.electronAPI.setCurrentRecordingSession?.( { @@ -173,7 +176,6 @@ export function useProjectLifecycle(input: Input) { timeline.setZoomRegions(editor.zoomRegions); timeline.setTrimRegions(editor.trimRegions); timeline.setClipRegions(editor.clipRegions); - // An explicit empty clip list means the user deleted all footage, not a legacy project. refs.clipInitializedRef.current = Array.isArray(persistedEditor.clipRegions); refs.autoFullTrackClipIdRef.current = null; refs.autoFullTrackClipEndMsRef.current = null; @@ -359,6 +361,8 @@ export function useProjectLifecycle(input: Input) { refs.nextAnnotationZIndexRef.current = 1; refs.pendingFreshRecordingAutoSuggestTelemetryCountRef.current = 0; refs.autoSuggestedVideoPathRef.current = null; + refs.pendingFreshRecordingManualZoomPathRef.current = null; + refs.manualRecordingZoomsAppliedVideoPathRef.current = null; current.resetHistory(); }, []); const handleUploadWebcam = useCallback(async () => { From 3c01089a284da901dce9dd1af7e0061baac56d70 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:36:59 +0500 Subject: [PATCH 24/29] feat(recording): add manual-zoom to video editor CursorInteractionType --- src/components/video-editor/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/video-editor/types.ts b/src/components/video-editor/types.ts index 4c947ceae..358ff45da 100644 --- a/src/components/video-editor/types.ts +++ b/src/components/video-editor/types.ts @@ -27,6 +27,7 @@ export interface CursorTelemetryPoint { | "double-click" | "right-click" | "middle-click" + | "manual-zoom" | "mouseup"; cursorType?: | "arrow" From 11cd958362fce3bda707fea39290d2e932f00e98 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:39:33 +0500 Subject: [PATCH 25/29] feat(recording): extract CursorTelemetryPoint with manual-zoom type --- electron/cursorTelemetryPoint.d.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 electron/cursorTelemetryPoint.d.ts diff --git a/electron/cursorTelemetryPoint.d.ts b/electron/cursorTelemetryPoint.d.ts new file mode 100644 index 000000000..a548a09fb --- /dev/null +++ b/electron/cursorTelemetryPoint.d.ts @@ -0,0 +1,25 @@ +// Global cursor telemetry point shape used by preload/renderer APIs. +interface CursorTelemetryPoint { + timeMs: number; + cx: number; + cy: number; + pressure?: number; + interactionType?: + | "move" + | "click" + | "double-click" + | "right-click" + | "middle-click" + | "manual-zoom" + | "mouseup"; + cursorType?: + | "arrow" + | "text" + | "pointer" + | "crosshair" + | "open-hand" + | "closed-hand" + | "resize-ew" + | "resize-ns" + | "not-allowed"; +} From e98308004bd37d450f1a4a675ac8ccc591ccc57d Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:39:55 +0500 Subject: [PATCH 26/29] revert: remove split CursorTelemetryPoint file pending electron-env update --- electron/cursorTelemetryPoint.d.ts | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 electron/cursorTelemetryPoint.d.ts diff --git a/electron/cursorTelemetryPoint.d.ts b/electron/cursorTelemetryPoint.d.ts deleted file mode 100644 index a548a09fb..000000000 --- a/electron/cursorTelemetryPoint.d.ts +++ /dev/null @@ -1,25 +0,0 @@ -// Global cursor telemetry point shape used by preload/renderer APIs. -interface CursorTelemetryPoint { - timeMs: number; - cx: number; - cy: number; - pressure?: number; - interactionType?: - | "move" - | "click" - | "double-click" - | "right-click" - | "middle-click" - | "manual-zoom" - | "mouseup"; - cursorType?: - | "arrow" - | "text" - | "pointer" - | "crosshair" - | "open-hand" - | "closed-hand" - | "resize-ew" - | "resize-ns" - | "not-allowed"; -} From a0049c81199bf9762ae7c4763ac793bacd3d8811 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:40:49 +0500 Subject: [PATCH 27/29] chore: size probe small first --- electron/.size-probe-30k.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 electron/.size-probe-30k.txt diff --git a/electron/.size-probe-30k.txt b/electron/.size-probe-30k.txt new file mode 100644 index 000000000..1f9b84cca --- /dev/null +++ b/electron/.size-probe-30k.txt @@ -0,0 +1,3 @@ +// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx From 8b7974b74dd3342e8df1ff5da3560ff48768e83a Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:41:27 +0500 Subject: [PATCH 28/29] chore: remove size probe file --- electron/.size-probe-30k.txt | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 electron/.size-probe-30k.txt diff --git a/electron/.size-probe-30k.txt b/electron/.size-probe-30k.txt deleted file mode 100644 index 1f9b84cca..000000000 --- a/electron/.size-probe-30k.txt +++ /dev/null @@ -1,3 +0,0 @@ -// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -// probe xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx From cbaae961801c6ad17cd267b4bedf2e8267b1ece1 Mon Sep 17 00:00:00 2001 From: Muhammad Ali <96878085+muhammad-a-dev@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:03:05 +0500 Subject: [PATCH 29/29] fix(recording): keep manual-zoom markers and allow close shortcut presses --- electron/ipc/cursor/telemetry.ts | 7 +++++- .../timeline/recordingZoomMarkers.test.ts | 16 +++++++++++++ .../timeline/recordingZoomMarkers.ts | 23 +++++++++++++------ 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 7b87ee39f..96090ae8f 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -254,7 +254,12 @@ export function pushCursorSample( } as CursorTelemetryPoint); if (activeCursorSamples.length > MAX_CURSOR_SAMPLES) { - activeCursorSamples.shift(); + const oldestNonMarkerIndex = activeCursorSamples.findIndex( + (sample) => sample.interactionType !== "manual-zoom", + ); + if (oldestNonMarkerIndex >= 0) { + activeCursorSamples.splice(oldestNonMarkerIndex, 1); + } } } diff --git a/src/components/video-editor/timeline/recordingZoomMarkers.test.ts b/src/components/video-editor/timeline/recordingZoomMarkers.test.ts index fc82a3426..c4161ab1e 100644 --- a/src/components/video-editor/timeline/recordingZoomMarkers.test.ts +++ b/src/components/video-editor/timeline/recordingZoomMarkers.test.ts @@ -36,4 +36,20 @@ describe("buildManualRecordingZoomRegions", () => { expect(regions).toEqual([{ start: 2100, end: 2600, focus: { cx: 1, cy: 0 } }]); }); + + it("keeps close shortcut presses as adjacent non-overlapping regions", () => { + const regions = buildManualRecordingZoomRegions({ + cursorTelemetry: [ + { timeMs: 1200, cx: 0.25, cy: 0.75, interactionType: "manual-zoom" }, + { timeMs: 1800, cx: 0.4, cy: 0.6, interactionType: "manual-zoom" }, + ], + totalMs: 5000, + defaultDurationMs: 1000, + }); + + expect(regions).toEqual([ + { start: 1200, end: 1800, focus: { cx: 0.25, cy: 0.75 } }, + { start: 1800, end: 2800, focus: { cx: 0.4, cy: 0.6 } }, + ]); + }); }); diff --git a/src/components/video-editor/timeline/recordingZoomMarkers.ts b/src/components/video-editor/timeline/recordingZoomMarkers.ts index 4133c4699..55a039e21 100644 --- a/src/components/video-editor/timeline/recordingZoomMarkers.ts +++ b/src/components/video-editor/timeline/recordingZoomMarkers.ts @@ -40,34 +40,43 @@ export function buildManualRecordingZoomRegions(params: { ) .sort((a, b) => a.timeMs - b.timeMs); - for (const marker of markers) { + for (let i = 0; i < markers.length; i++) { + const marker = markers[i]; const start = Math.max(0, Math.min(Math.round(marker.timeMs), totalMs)); if (start >= totalMs) { continue; } + // Only pre-existing reserved spans suppress markers; regions generated in + // this pass must not drop later accepted shortcut presses. const overlapsExisting = reserved.some((span) => start >= span.start && start < span.end); if (overlapsExisting) { continue; } const nextSpan = reserved.find((span) => span.start > start); - const end = Math.min(start + duration, nextSpan?.start ?? totalMs, totalMs); + const nextMarker = markers[i + 1]; + const nextMarkerStart = + nextMarker !== undefined + ? Math.max(0, Math.min(Math.round(nextMarker.timeMs), totalMs)) + : undefined; + + let end = Math.min(start + duration, nextSpan?.start ?? totalMs, totalMs); + if (nextMarkerStart !== undefined && nextMarkerStart > start) { + end = Math.min(end, nextMarkerStart); + } if (end <= start) { continue; } - const region = { + regions.push({ start, end, focus: { cx: Math.max(0, Math.min(marker.cx, 1)), cy: Math.max(0, Math.min(marker.cy, 1)), }, - }; - regions.push(region); - reserved.push(region); - reserved.sort((a, b) => a.start - b.start); + }); } return regions;