diff --git a/electron/windows.ts b/electron/windows.ts index 478584b55..f31e233e7 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -294,8 +294,6 @@ function setHudOverlayMousePassthrough(ignore: boolean) { } if (hudOverlayRecordingActive) { - hudOverlayFallbackExpanded = false; - applyHudOverlayBounds(); hudOverlayWindow.setIgnoreMouseEvents(false); return; } @@ -639,6 +637,10 @@ export function setHudOverlayRecordingActive(recording: boolean): void { hudOverlayFallbackExpanded = false; applyHudOverlayBounds(); setHudOverlayMousePassthrough(!hudOverlayRecordingActive); + + if (hudOverlayWindow && !hudOverlayWindow.isDestroyed()) { + hudOverlayWindow.setSkipTaskbar(!recording); + } } export function createUpdateToastWindow(): BrowserWindow { diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index d7dc51bc4..a84ef632e 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -164,6 +164,7 @@ function LaunchWindowContent() { hudContentRef, hudBarRef, recordingWebcamPreviewContainerRef, + recording, }); const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = @@ -427,7 +428,7 @@ function LaunchWindowContent() { const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; const useNativeHudBarDrag = - platform === "linux" || hudOverlayMousePassthroughSupported === false; + platform === "linux" || hudOverlayMousePassthroughSupported === false || recording; return ( ; hudBarRef: RefObject; recordingWebcamPreviewContainerRef: RefObject; + recording: boolean; }) { const [recordingHudOffset, setRecordingHudOffset] = useState(DEFAULT_RECORDING_HUD_OFFSET); const [isHudDragging, setIsHudDragging] = useState(false); @@ -34,6 +36,16 @@ export function useHudBarDrag({ const hudDragMoveRafRef = useRef(null); const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); + useEffect(() => { + if (recording) { + recordingHudOffsetRef.current = DEFAULT_RECORDING_HUD_OFFSET; + setRecordingHudOffset(DEFAULT_RECORDING_HUD_OFFSET); + if (hudBarTransformRef.current) { + hudBarTransformRef.current.style.transform = "translate3d(0px, 0px, 0)"; + } + } + }, [recording]); + useEffect(() => { recordingHudOffsetRef.current = recordingHudOffset; if (!isHudDraggingRef.current && hudBarTransformRef.current) { diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 0e00d8d9f..8f1b43580 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -1,12 +1,12 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { type AspectRatio } from "@/utils/aspectRatioUtils"; interface CropRegion { - x: number; // 0-1 normalized - y: number; // 0-1 normalized - width: number; // 0-1 normalized - height: number; // 0-1 normalized + x: number; + y: number; + width: number; + height: number; } interface CropControlProps { @@ -16,14 +16,37 @@ interface CropControlProps { aspectRatio: AspectRatio; } -type DragHandle = "top" | "right" | "bottom" | "left" | null; +type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null; + +type CropRatioPreset = "free" | "16:9" | "9:16" | "1:1" | "4:3" | "4:5" | "21:9"; + +const CROP_RATIO_PRESETS: Array<{ value: CropRatioPreset; label: string }> = [ + { value: "free", label: "Free" }, + { value: "16:9", label: "16:9" }, + { value: "9:16", label: "9:16" }, + { value: "1:1", label: "1:1" }, + { value: "4:3", label: "4:3" }, + { value: "4:5", label: "4:5" }, + { value: "21:9", label: "21:9" }, +]; + +function getRatioNumeric(preset: CropRatioPreset): number | null { + if (preset === "free") return null; + const [w, h] = preset.split(":").map(Number); + return w / h; +} export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { const canvasRef = useRef(null); const containerRef = useRef(null); - const [isDragging, setIsDragging] = useState(null); - const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); - const [initialCrop, setInitialCrop] = useState(cropRegion); + const isDraggingRef = useRef(null); + const dragStartRef = useRef({ x: 0, y: 0 }); + const initialCropRef = useRef(cropRegion); + const [cropRatioPreset, setCropRatioPreset] = useState("free"); + + const videoAspectRatio = videoElement?.videoWidth && videoElement?.videoHeight + ? videoElement.videoWidth / videoElement.videoHeight + : 16 / 9; useEffect(() => { if (!videoElement || !canvasRef.current) return; @@ -39,10 +62,7 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont let isCancelled = false; const draw = () => { - if (isCancelled) { - return; - } - + if (isCancelled) return; if (videoElement.readyState >= 2) { ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height); @@ -57,106 +77,160 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont }; }, [videoElement]); - const getContainerRect = () => { - return ( - containerRef.current?.getBoundingClientRect() || { - width: 0, - height: 0, - left: 0, - top: 0, + const targetNormalizedRatio = useMemo(() => { + if (cropRatioPreset === "free") return null; + const ratio = getRatioNumeric(cropRatioPreset); + return ratio !== null ? ratio / videoAspectRatio : null; + }, [cropRatioPreset, videoAspectRatio]); + + const constrainToRatio = useCallback( + (crop: CropRegion, handle: Exclude): CropRegion => { + if (targetNormalizedRatio === null) return crop; + + const { x, y, width, height } = crop; + const MIN_SIZE = 0.1; + + switch (handle) { + case "bottom": { + let h = Math.max(MIN_SIZE, Math.min(height, 1 - y)); + let w = h * targetNormalizedRatio; + if (x + w > 1) { + w = Math.max(MIN_SIZE, 1 - x); + h = w / targetNormalizedRatio; + } + if (y + h > 1) { + h = Math.max(MIN_SIZE, 1 - y); + w = h * targetNormalizedRatio; + } + return { x, y, width: w, height: h }; + } + case "right": { + let w = Math.max(MIN_SIZE, Math.min(width, 1 - x)); + let h = w / targetNormalizedRatio; + if (y + h > 1) { + h = Math.max(MIN_SIZE, 1 - y); + w = h * targetNormalizedRatio; + } + if (x + w > 1) { + w = Math.max(MIN_SIZE, 1 - x); + h = w / targetNormalizedRatio; + } + return { x, y, width: w, height: h }; + } + case "top": { + const bottomEdge = y + height; + let h = Math.max(MIN_SIZE, bottomEdge - y); + let w = h * targetNormalizedRatio; + if (x + w > 1) { + w = Math.max(MIN_SIZE, 1 - x); + h = w / targetNormalizedRatio; + } + const ny = Math.max(0, bottomEdge - h); + return { x, y: ny, width: w, height: bottomEdge - ny }; + } + case "left": { + const rightEdge = x + width; + let w = Math.max(MIN_SIZE, rightEdge - x); + let h = w / targetNormalizedRatio; + if (y + h > 1) { + h = Math.max(MIN_SIZE, 1 - y); + w = h * targetNormalizedRatio; + } + const nx = Math.max(0, rightEdge - w); + return { x: nx, y, width: rightEdge - nx, height: h }; + } + default: + return crop; } - ); - }; + }, + [targetNormalizedRatio], + ); const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => { e.stopPropagation(); e.preventDefault(); - const rect = getContainerRect(); - if (rect.width <= 0 || rect.height <= 0) { - return; - } + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return; - setIsDragging(handle); - setDragStart({ + isDraggingRef.current = handle; + dragStartRef.current = { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, - }); - setInitialCrop(cropRegion); - - e.currentTarget.setPointerCapture(e.pointerId); + }; + initialCropRef.current = cropRegion; + document.body.style.cursor = "grabbing"; }; const handlePointerMove = (e: React.PointerEvent) => { - if (!isDragging) return; + const handle = isDraggingRef.current; + if (!handle) return; - const rect = getContainerRect(); - if (rect.width <= 0 || rect.height <= 0) { - return; - } + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect || rect.width <= 0 || rect.height <= 0) return; const currentX = (e.clientX - rect.left) / rect.width; const currentY = (e.clientY - rect.top) / rect.height; - const deltaX = currentX - dragStart.x; - const deltaY = currentY - dragStart.y; + const deltaX = currentX - dragStartRef.current.x; + const deltaY = currentY - dragStartRef.current.y; + const init = initialCropRef.current; - let newCrop = { ...initialCrop }; + let newCrop = { ...init }; - switch (isDragging) { + switch (handle) { + case "move": { + let newX = init.x + deltaX; + let newY = init.y + deltaY; + newX = Math.max(0, Math.min(newX, 1 - init.width)); + newY = Math.max(0, Math.min(newY, 1 - init.height)); + newCrop.x = newX; + newCrop.y = newY; + break; + } case "top": { - const newY = Math.max(0, initialCrop.y + deltaY); - const bottom = initialCrop.y + initialCrop.height; + const newY = Math.max(0, init.y + deltaY); + const bottom = init.y + init.height; newCrop.y = Math.min(newY, bottom - 0.1); newCrop.height = bottom - newCrop.y; break; } case "bottom": - newCrop.height = Math.max( - 0.1, - Math.min(initialCrop.height + deltaY, 1 - initialCrop.y), - ); + newCrop.height = Math.max(0.1, Math.min(init.height + deltaY, 1 - init.y)); break; case "left": { - const newX = Math.max(0, initialCrop.x + deltaX); - const right = initialCrop.x + initialCrop.width; + const newX = Math.max(0, init.x + deltaX); + const right = init.x + init.width; newCrop.x = Math.min(newX, right - 0.1); newCrop.width = right - newCrop.x; break; } case "right": - newCrop.width = Math.max( - 0.1, - Math.min(initialCrop.width + deltaX, 1 - initialCrop.x), - ); + newCrop.width = Math.max(0.1, Math.min(init.width + deltaX, 1 - init.x)); break; } + if (targetNormalizedRatio !== null) { + newCrop = constrainToRatio(newCrop, handle); + } + onCropChange(newCrop); }; - const handlePointerUp = (e: React.PointerEvent) => { - if (isDragging) { - try { - e.currentTarget.releasePointerCapture(e.pointerId); - } catch { - /* Pointer capture may already be released while ending the drag. */ - } - } - setIsDragging(null); + const handlePointerUp = () => { + if (!isDraggingRef.current) return; + document.body.style.cursor = ""; + isDraggingRef.current = null; }; const cropPixelX = cropRegion.x * 100; const cropPixelY = cropRegion.y * 100; const cropPixelWidth = cropRegion.width * 100; const cropPixelHeight = cropRegion.height * 100; - const videoAspectRatio = videoElement - ? videoElement.videoWidth / videoElement.videoHeight - : 16 / 9; const isVideoPortrait = videoAspectRatio < 1; const maxContainerWidth = isVideoPortrait ? "40vw" : "75vw"; const maxContainerHeight = "75vh"; return ( -
+
-
+
+
handlePointerDown(e, "move")} + />
handlePointerDown(e, "top")} />
handlePointerDown(e, "bottom")} />
handlePointerDown(e, "left")} />
handlePointerDown(e, "right")} />
+ +
+ {CROP_RATIO_PRESETS.map((preset) => { + const isActive = cropRatioPreset === preset.value; + return ( + + ); + })} +
); } diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index 0b23fb692..ab5ea42d7 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -6882,7 +6882,7 @@ export default function VideoEditor() { className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200" onClick={handleCancelCropEditor} /> -
+