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/README.md b/README.md index 0e9ed4d..53d5479 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ Ask DeepWiki +   + + Join Discord +

#

OpenScreen

diff --git a/electron-builder.json5 b/electron-builder.json5 index a8f1dc1..40fce0a 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -20,14 +20,15 @@ "!CONTRIBUTING.md", "!LICENSE" ], - "extraResources": [ - { - "from": "public/wallpapers", - "to": "assets/wallpapers" - } - ], - - "mac": { + "extraResources": [ + { + "from": "public/wallpapers", + "to": "assets/wallpapers" + } + ], + "publish": [{"provider": "github"}], + + "mac": { "hardenedRuntime": false, "target": [ { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index c189b80..83306ee 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -5,6 +5,7 @@ import { FaRegStopCircle } from "react-icons/fa"; import { FaFolderOpen } from "react-icons/fa6"; import { FiMinus, FiX } from "react-icons/fi"; import { + MdCancel, MdMic, MdMicOff, MdMonitor, @@ -45,6 +46,7 @@ const ICON_CONFIG = { resume: { icon: BsPlayCircle, size: ICON_SIZE }, stop: { icon: FaRegStopCircle, size: ICON_SIZE }, restart: { icon: MdRestartAlt, size: ICON_SIZE }, + cancel: { icon: MdCancel, size: ICON_SIZE }, record: { icon: BsRecordCircle, size: ICON_SIZE }, videoFile: { icon: MdVideoFile, size: ICON_SIZE }, folder: { icon: FaFolderOpen, size: ICON_SIZE }, @@ -84,6 +86,7 @@ export function LaunchWindow() { toggleRecording, togglePaused, restartRecording, + cancelRecording, microphoneEnabled, setMicrophoneEnabled, microphoneDeviceId, @@ -478,6 +481,18 @@ export function LaunchWindow() { )} + {/* Cancel recording */} + {recording && ( + + + + )} + {/* Open video file */} + ); + })} + + {selectedZoomFocusMode === "auto" && ( +

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

+ )} + + )} {zoomEnabled && ( + ))} + + + )} )} diff --git a/src/components/video-editor/ShortcutsConfigDialog.tsx b/src/components/video-editor/ShortcutsConfigDialog.tsx index 775cb8c..faa7513 100644 --- a/src/components/video-editor/ShortcutsConfigDialog.tsx +++ b/src/components/video-editor/ShortcutsConfigDialog.tsx @@ -197,12 +197,14 @@ export function ShortcutsConfigDialog() {

{t("fixed")}

- {FIXED_SHORTCUTS.map(({ label, display }) => ( + {FIXED_SHORTCUTS.map(({ i18nKey, label, display }) => (
- {label} + + {t(`fixedActions.${i18nKey}`, { defaultValue: label })} + {display} diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 304d10f..549aa37 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -20,6 +20,7 @@ import { type GifSizePreset, VideoExporter, } from "@/lib/exporter"; +import { computeFrameStepTime } from "@/lib/frameStep"; import type { ProjectMedia } from "@/lib/recordingSession"; import { matchesShortcut } from "@/lib/shortcuts"; import { @@ -56,6 +57,7 @@ import { type TrimRegion, type ZoomDepth, type ZoomFocus, + type ZoomFocusMode, type ZoomRegion, } from "./types"; import VideoPlayback, { VideoPlaybackRef } from "./VideoPlayback"; @@ -84,6 +86,7 @@ export default function VideoEditor() { padding, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, } = editorState; @@ -98,6 +101,10 @@ export default function VideoEditor() { const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); + const currentTimeRef = useRef(currentTime); + currentTimeRef.current = currentTime; + const durationRef = useRef(duration); + durationRef.current = duration; const [cursorTelemetry, setCursorTelemetry] = useState([]); const [selectedZoomId, setSelectedZoomId] = useState(null); const [selectedTrimId, setSelectedTrimId] = useState(null); @@ -195,6 +202,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 +272,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -287,6 +296,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -380,6 +390,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -434,6 +445,7 @@ export default function VideoEditor() { annotationRegions, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, exportFormat, @@ -688,6 +700,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) })); @@ -926,6 +950,40 @@ export default function VideoEditor() { return; } + // Frame-step navigation (arrow keys, no modifiers) + if ( + (e.key === "ArrowLeft" || e.key === "ArrowRight") && + !e.ctrlKey && + !e.metaKey && + !e.shiftKey && + !e.altKey + ) { + const target = e.target; + if ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && + (target.isContentEditable || + target.closest('[role="separator"], [role="slider"], [role="spinbutton"]'))) + ) { + return; + } + e.preventDefault(); + const video = videoPlaybackRef.current?.video; + if (!video) { + return; + } + const direction = e.key === "ArrowLeft" ? "backward" : "forward"; + const newTime = computeFrameStepTime( + video.currentTime, + Number.isFinite(video.duration) ? video.duration : durationRef.current, + direction, + ); + video.currentTime = newTime; + return; + } + const isInput = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement; @@ -1090,9 +1148,11 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, previewWidth, previewHeight, + cursorTelemetry, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1221,9 +1281,11 @@ export default function VideoEditor() { cropRegion, annotationRegions, webcamLayoutPreset, + webcamMaskShape, webcamPosition, previewWidth, previewHeight, + cursorTelemetry, onProgress: (progress: ExportProgress) => { setExportProgress(progress); }, @@ -1289,9 +1351,11 @@ export default function VideoEditor() { isPlaying, aspectRatio, webcamLayoutPreset, + webcamMaskShape, webcamPosition, exportQuality, handleExportSaved, + cursorTelemetry, ], ); @@ -1473,6 +1537,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 +1567,7 @@ export default function VideoEditor() { onSelectAnnotation={handleSelectAnnotation} onAnnotationPositionChange={handleAnnotationPositionChange} onAnnotationSizeChange={handleAnnotationSizeChange} + cursorTelemetry={cursorTelemetry} />
@@ -1584,6 +1650,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 +1686,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 && ( -