diff --git a/electron/ipc/cursor/telemetry.ts b/electron/ipc/cursor/telemetry.ts index 73f62714e..96090ae8f 100644 --- a/electron/ipc/cursor/telemetry.ts +++ b/electron/ipc/cursor/telemetry.ts @@ -60,7 +60,8 @@ export function normalizeCursorTelemetrySamples(rawSamples: unknown): CursorTele point.interactionType === "right-click" || point.interactionType === "middle-click" || point.interactionType === "move" || - point.interactionType === "mouseup" + point.interactionType === "mouseup" || + point.interactionType === "manual-zoom" ? point.interactionType : undefined, cursorType: @@ -253,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); + } } } @@ -262,6 +268,21 @@ export function sampleCursorPoint() { 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) { 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/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 { diff --git a/electron/recordingZoomShortcut.ts b/electron/recordingZoomShortcut.ts new file mode 100644 index 000000000..ece0ad309 --- /dev/null +++ b/electron/recordingZoomShortcut.ts @@ -0,0 +1,59 @@ +import { app, 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; +let beforeQuitHookInstalled = false; + +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}`); + } +} + +function ensureBeforeQuitHook() { + if (beforeQuitHookInstalled) { + return; + } + beforeQuitHookInstalled = true; + app.on("before-quit", () => { + unregisterRecordingZoomShortcut(); + }); +} + +export function registerRecordingZoomShortcut() { + ensureBeforeQuitHook(); + 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; +} 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, 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, + ]); +} 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, 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, 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, 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 () => { diff --git a/src/components/video-editor/project/useProjectOpenActions.ts b/src/components/video-editor/project/useProjectOpenActions.ts index 33ea8f3f7..8e6257e48 100644 --- a/src/components/video-editor/project/useProjectOpenActions.ts +++ b/src/components/video-editor/project/useProjectOpenActions.ts @@ -21,6 +21,7 @@ type UseProjectOpenActionsInput = { appearance: ReturnType; videoPlaybackRef: RefObject; pendingFreshRecordingAutoZoomPathRef: MutableRefObject; + pendingFreshRecordingManualZoomPathRef: MutableRefObject; hasUnsavedChanges: boolean; setIsPlaying: Set; setCurrentTime: Set; @@ -40,6 +41,7 @@ export function useProjectOpenActions({ appearance, videoPlaybackRef, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, hasUnsavedChanges, setIsPlaying, setCurrentTime, @@ -140,6 +142,7 @@ export function useProjectOpenActions({ project.setCurrentProjectPath(null); project.setLastSavedSnapshot(null); resetSourceScopedEditorState(); + pendingFreshRecordingManualZoomPathRef.current = sourceVideoUrl; pendingFreshRecordingAutoZoomPathRef.current = appearance.autoApplyFreshRecordingAutoZooms ? sourceVideoUrl : null; appearance.setWebcam((previous) => ({ @@ -169,6 +172,7 @@ export function useProjectOpenActions({ setDuration, resetSourceScopedEditorState, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, applySessionPresentation, refreshProjectLibrary, ]); diff --git a/src/components/video-editor/state/useEditorUiState.ts b/src/components/video-editor/state/useEditorUiState.ts index 4d6e34d58..95a0f72a9 100644 --- a/src/components/video-editor/state/useEditorUiState.ts +++ b/src/components/video-editor/state/useEditorUiState.ts @@ -66,6 +66,8 @@ export function useEditorUiState( 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); @@ -165,6 +167,8 @@ export function useEditorUiState( nextAnnotationZIndexRef, autoSuggestedVideoPathRef, pendingFreshRecordingAutoZoomPathRef, + pendingFreshRecordingManualZoomPathRef, + manualRecordingZoomsAppliedVideoPathRef, pendingFreshRecordingAutoSuggestTimeoutRef, pendingFreshRecordingAutoSuggestTelemetryCountRef, timelineRef, 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..c4161ab1e --- /dev/null +++ b/src/components/video-editor/timeline/recordingZoomMarkers.test.ts @@ -0,0 +1,55 @@ +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 } }]); + }); + + 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 new file mode 100644 index 000000000..55a039e21 --- /dev/null +++ b/src/components/video-editor/timeline/recordingZoomMarkers.ts @@ -0,0 +1,83 @@ +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 (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 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; + } + + regions.push({ + start, + end, + focus: { + cx: Math.max(0, Math.min(marker.cx, 1)), + cy: Math.max(0, Math.min(marker.cy, 1)), + }, + }); + } + + return regions; +} 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"