diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 790884e..4cc446f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -94,6 +94,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: linux-installer - path: release/**/*.AppImage + path: | + release/**/*.AppImage + release/**/*.zsync retention-days: 30 - diff --git a/electron-builder.json5 b/electron-builder.json5 index 8e938ad..18498df 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -28,10 +28,10 @@ ], "mac": { - "notarize": false, + "notarize": false, "hardenedRuntime": true, - "entitlements": "macos.entitlements", - "entitlementsInherit": "macos.entitlements", + "entitlements": "macos.entitlements", + "entitlementsInherit": "macos.entitlements", "target": [ { "target": "dmg", @@ -40,13 +40,13 @@ ], "icon": "icons/icons/mac/icon.icns", "artifactName": "${productName}-Mac-${arch}-${version}-Installer.${ext}", - "extendInfo": { - "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", - "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", - "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", - "NSCameraUseContinuityCameraDeviceType": true, - "com.apple.security.device.audio-input": true - } + "extendInfo": { + "NSAudioCaptureUsageDescription": "OpenScreen needs audio capture permission to record system audio.", + "NSMicrophoneUsageDescription": "OpenScreen needs microphone access to record voice audio.", + "NSCameraUsageDescription": "OpenScreen needs camera access to record webcam video.", + "NSCameraUseContinuityCameraDeviceType": true, + "com.apple.security.device.audio-input": true + } }, "linux": { "target": [ @@ -56,14 +56,14 @@ "artifactName": "${productName}-Linux-${version}.${ext}", "category": "AudioVideo" }, - "win": { - "target": [ - "nsis" - ], - "icon": "icons/icons/win/icon.ico" - }, - "nsis": { - "oneClick": false, - "allowToChangeInstallationDirectory": true - } -} + "win": { + "target": [ + "nsis" + ], + "icon": "icons/icons/win/icon.ico" + }, + "nsis": { + "oneClick": false, + "allowToChangeInstallationDirectory": true + } +} diff --git a/src/components/video-editor/SettingsPanel.tsx b/src/components/video-editor/SettingsPanel.tsx index f5afe35..7e556b8 100644 --- a/src/components/video-editor/SettingsPanel.tsx +++ b/src/components/video-editor/SettingsPanel.tsx @@ -51,7 +51,9 @@ import type { FigureData, PlaybackSpeed, WebcamLayoutPreset, + WebcamMaskShape, ZoomDepth, + ZoomFocusMode, } from "./types"; import { SPEED_OPTIONS } from "./types"; @@ -92,6 +94,9 @@ interface SettingsPanelProps { onWallpaperChange: (path: string) => void; selectedZoomDepth?: ZoomDepth | null; onZoomDepthChange?: (depth: ZoomDepth) => void; + selectedZoomFocusMode?: ZoomFocusMode | null; + onZoomFocusModeChange?: (mode: ZoomFocusMode) => void; + hasCursorTelemetry?: boolean; selectedZoomId?: string | null; onZoomDelete?: (id: string) => void; selectedTrimId?: string | null; @@ -143,6 +148,8 @@ interface SettingsPanelProps { hasWebcam?: boolean; webcamLayoutPreset?: WebcamLayoutPreset; onWebcamLayoutPresetChange?: (preset: WebcamLayoutPreset) => void; + webcamMaskShape?: import("./types").WebcamMaskShape; + onWebcamMaskShapeChange?: (shape: import("./types").WebcamMaskShape) => void; } export default SettingsPanel; @@ -161,6 +168,9 @@ export function SettingsPanel({ onWallpaperChange, selectedZoomDepth, onZoomDepthChange, + selectedZoomFocusMode, + onZoomFocusModeChange, + hasCursorTelemetry = false, selectedZoomId, onZoomDelete, selectedTrimId, @@ -211,6 +221,8 @@ export function SettingsPanel({ hasWebcam = false, webcamLayoutPreset = "picture-in-picture", onWebcamLayoutPresetChange, + webcamMaskShape = "rectangle", + onWebcamMaskShapeChange, }: SettingsPanelProps) { const t = useScopedT("settings"); const [wallpaperPaths, setWallpaperPaths] = useState([]); @@ -500,6 +512,41 @@ export function SettingsPanel({ {!zoomEnabled && (

{t("zoom.selectRegion")}

)} + {zoomEnabled && hasCursorTelemetry && ( +
+ + {t("zoom.focusMode.title")} + +
+ {(["manual", "auto"] as const).map((mode) => { + const isActive = selectedZoomFocusMode === mode; + return ( + + ); + })} +
+ {selectedZoomFocusMode === "auto" && ( +

+ {t("zoom.focusMode.autoDescription")} +

+ )} +
+ )} {zoomEnabled && ( + ))} + + + )} )} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 304d10f..4e5e978 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -56,6 +56,7 @@ import { type TrimRegion, type ZoomDepth, type ZoomFocus, + type ZoomFocusMode, type ZoomRegion, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; @@ -84,6 +85,7 @@ export default function VideoEditor() { padding, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, } = editorState; @@ -195,6 +197,7 @@ export default function VideoEditor() { annotationRegions: normalizedEditor.annotationRegions, aspectRatio: normalizedEditor.aspectRatio, webcamLayoutPreset: normalizedEditor.webcamLayoutPreset, + webcamMaskShape: normalizedEditor.webcamMaskShape, webcamPosition: normalizedEditor.webcamPosition, }); setExportQuality(normalizedEditor.exportQuality); @@ -264,6 +267,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -287,6 +291,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -380,6 +385,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -434,6 +440,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -688,6 +695,18 @@ export default function VideoEditor() { [selectedZoomId, pushState], ); + const handleZoomFocusModeChange = useCallback( + (focusMode: ZoomFocusMode) => { + if (!selectedZoomId) return; + pushState((prev) => ({ + zoomRegions: prev.zoomRegions.map((region) => + region.id === selectedZoomId ? { ...region, focusMode } : region, + ), + })); + }, + [selectedZoomId, pushState], + ); + const handleZoomDelete = useCallback( (id: string) => { pushState((prev) => ({ zoomRegions: prev.zoomRegions.filter((r) => r.id !== id) })); @@ -1090,9 +1109,11 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, previewWidth, previewHeight, + cursorTelemetry, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1221,9 +1242,11 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, previewWidth, previewHeight, + cursorTelemetry, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1289,9 +1312,11 @@ export default function VideoEditor() { isPlaying, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, handleExportSaved, + cursorTelemetry, ], ); @@ -1473,6 +1498,7 @@ export default function VideoEditor() { videoPath={videoPath || ""} webcamVideoPath={webcamVideoPath || undefined} webcamLayoutPreset={webcamLayoutPreset} + webcamMaskShape={webcamMaskShape} webcamPosition={webcamPosition} onWebcamPositionChange={(pos) => updateState({ webcamPosition: pos })} onWebcamPositionDragEnd={commitState} @@ -1502,6 +1528,7 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTelemetry={cursorTelemetry} /> @@ -1584,6 +1611,13 @@ export default function VideoEditor() { selectedZoomId ? zoomRegions.find((z) => z.id === selectedZoomId)?.depth : null } onZoomDepthChange={(depth) => selectedZoomId && handleZoomDepthChange(depth)} + selectedZoomFocusMode={ + selectedZoomId + ? (zoomRegions.find((z) => z.id === selectedZoomId)?.focusMode ?? "manual") + : null + } + onZoomFocusModeChange={(mode) => selectedZoomId && handleZoomFocusModeChange(mode)} + hasCursorTelemetry={cursorTelemetry.length > 0} selectedZoomId={selectedZoomId} onZoomDelete={handleZoomDelete} selectedTrimId={selectedTrimId} @@ -1613,6 +1647,8 @@ export default function VideoEditor() { webcamPosition: preset === "vertical-stack" ? null : webcamPosition, }) } + webcamMaskShape={webcamMaskShape} + onWebcamMaskShapeChange={(shape) => pushState({ webcamMaskShape: shape })} videoElement={videoPlaybackRef.current?.video || null} exportQuality={exportQuality} onExportQualityChange={setExportQuality} diff --git a/src/components/video-editor/VideoPlayback.tsx b/src/components/video-editor/VideoPlayback.tsx index 71030bb..d659afe 100644 --- a/src/components/video-editor/VideoPlayback.tsx +++ b/src/components/video-editor/VideoPlayback.tsx @@ -25,6 +25,7 @@ import { type StyledRenderRect, type WebcamLayoutPreset, } from "@/lib/compositeLayout"; +import { getCssClipPath } from "@/lib/webcamMaskShapes"; import { type AspectRatio, formatAspectRatioForCSS, @@ -41,10 +42,14 @@ import { type ZoomRegion, } from "./types"; import { + AUTO_FOLLOW_RAMP_DISTANCE, + AUTO_FOLLOW_SMOOTHING_FACTOR, + AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, DEFAULT_FOCUS, ZOOM_SCALE_DEADZONE, ZOOM_TRANSLATION_DEADZONE_PX, } from "./videoPlayback/constants"; +import { adaptiveSmoothFactor, smoothCursorFocus } from "./videoPlayback/cursorFollowUtils"; import { clampFocusToStage as clampFocusToStageUtil } from "./videoPlayback/focusUtils"; import { layoutVideoContent as layoutVideoContentUtil } from "./videoPlayback/layoutUtils"; import { clamp01 } from "./videoPlayback/mathUtils"; @@ -63,6 +68,7 @@ interface VideoPlaybackProps { videoPath: string; webcamVideoPath?: string; webcamLayoutPreset: WebcamLayoutPreset; + webcamMaskShape?: import("./types").WebcamMaskShape; webcamPosition?: { cx: number; cy: number } | null; onWebcamPositionChange?: (position: { cx: number; cy: number }) => void; onWebcamPositionDragEnd?: () => void; @@ -93,6 +99,7 @@ interface VideoPlaybackProps { onSelectAnnotation?: (id: string | null) => void; onAnnotationPositionChange?: (id: string, position: { x: number; y: number }) => void; onAnnotationSizeChange?: (id: string, size: { width: number; height: number }) => void; + cursorTelemetry?: import("./types").CursorTelemetryPoint[]; } export interface VideoPlaybackRef { @@ -111,6 +118,7 @@ const VideoPlayback = forwardRef( videoPath, webcamVideoPath, webcamLayoutPreset, + webcamMaskShape, webcamPosition, onWebcamPositionChange, onWebcamPositionDragEnd, @@ -141,6 +149,7 @@ const VideoPlayback = forwardRef( onSelectAnnotation, onAnnotationPositionChange, onAnnotationSizeChange, + cursorTelemetry = [], }, ref, ) => { @@ -160,6 +169,7 @@ const VideoPlayback = forwardRef( const [webcamDimensions, setWebcamDimensions] = useState(null); const currentTimeRef = useRef(0); const zoomRegionsRef = useRef([]); + const cursorTelemetryRef = useRef([]); const selectedZoomIdRef = useRef(null); const animationStateRef = useRef({ scale: 1, @@ -194,6 +204,8 @@ const VideoPlayback = forwardRef( const onTimeUpdateRef = useRef(onTimeUpdate); const onPlayStateChangeRef = useRef(onPlayStateChange); const videoReadyRafRef = useRef(null); + const smoothedAutoFocusRef = useRef(null); + const prevTargetProgressRef = useRef(0); const clampFocusToStage = useCallback((focus: ZoomFocus, depth: ZoomDepth) => { return clampFocusToStageUtil(focus, depth, stageSizeRef.current); @@ -272,6 +284,7 @@ const VideoPlayback = forwardRef( webcamDimensions, webcamLayoutPreset, webcamPosition, + webcamMaskShape, }); if (result) { @@ -302,6 +315,7 @@ const VideoPlayback = forwardRef( webcamDimensions, webcamLayoutPreset, webcamPosition, + webcamMaskShape, ]); useEffect(() => { @@ -379,6 +393,7 @@ const VideoPlayback = forwardRef( if (!regionId) return; const region = zoomRegionsRef.current.find((r) => r.id === regionId); if (!region) return; + if (region.focusMode === "auto") return; onSelectZoom(region.id); event.preventDefault(); isDraggingFocusRef.current = true; @@ -462,6 +477,10 @@ const VideoPlayback = forwardRef( zoomRegionsRef.current = zoomRegions; }, [zoomRegions]); + useEffect(() => { + cursorTelemetryRef.current = cursorTelemetry; + }, [cursorTelemetry]); + useEffect(() => { selectedZoomIdRef.current = selectedZoomId; }, [selectedZoomId]); @@ -830,10 +849,16 @@ const VideoPlayback = forwardRef( }; const ticker = () => { + const bm = baseMaskRef.current; + const ss = stageSizeRef.current; + const viewportRatio = + bm.width > 0 && bm.height > 0 + ? { widthRatio: ss.width / bm.width, heightRatio: ss.height / bm.height } + : undefined; const { region, strength, blendedScale, transition } = findDominantRegion( zoomRegionsRef.current, currentTimeRef.current, - { connectZooms: true }, + { connectZooms: true, cursorTelemetry: cursorTelemetryRef.current, viewportRatio }, ); const defaultFocus = DEFAULT_FOCUS; @@ -854,6 +879,47 @@ const VideoPlayback = forwardRef( targetFocus = regionFocus; targetProgress = strength; + // Apply adaptive smoothing for auto-follow mode + if (region.focusMode === "auto" && !transition) { + const raw = targetFocus; + const isZoomingIn = + targetProgress < 0.999 && targetProgress >= prevTargetProgressRef.current; + if (targetProgress >= 0.999) { + // Full zoom: adaptive smoothing — moves faster when far, decelerates when close + const prev = smoothedAutoFocusRef.current ?? raw; + const factor = adaptiveSmoothFactor( + raw, + prev, + AUTO_FOLLOW_SMOOTHING_FACTOR, + AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, + AUTO_FOLLOW_RAMP_DISTANCE, + ); + const smoothed = smoothCursorFocus(raw, prev, factor); + smoothedAutoFocusRef.current = smoothed; + targetFocus = smoothed; + } else if (isZoomingIn) { + // Zoom-in: track cursor directly so zoom always aims at current cursor + // position; keep ref in sync to avoid snap when full-zoom begins + smoothedAutoFocusRef.current = raw; + } else { + // Zoom-out: keep smoothing for continuity — avoids snap at zoom-out start + const prev = smoothedAutoFocusRef.current ?? raw; + const factor = adaptiveSmoothFactor( + raw, + prev, + AUTO_FOLLOW_SMOOTHING_FACTOR, + AUTO_FOLLOW_SMOOTHING_FACTOR_MAX, + AUTO_FOLLOW_RAMP_DISTANCE, + ); + const smoothed = smoothCursorFocus(raw, prev, factor); + smoothedAutoFocusRef.current = smoothed; + targetFocus = smoothed; + } + } else if (region.focusMode !== "auto") { + smoothedAutoFocusRef.current = null; + } + prevTargetProgressRef.current = targetProgress; + // Handle connected zoom transitions (pan between adjacent zoom regions) if (transition) { const startTransform = computeZoomTransform({ @@ -1154,31 +1220,47 @@ const VideoPlayback = forwardRef( : "none", }} /> - {webcamVideoPath && ( -