diff --git a/src/components/launch/LaunchWindow.module.css b/src/components/launch/LaunchWindow.module.css index 3638e7563..8d7edf153 100644 --- a/src/components/launch/LaunchWindow.module.css +++ b/src/components/launch/LaunchWindow.module.css @@ -95,8 +95,6 @@ color: #4eeeb0; } - - .menuCard { width: 300px; max-height: 400px; diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index 80d9d2220..f89d36685 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -1,46 +1,46 @@ import { + ArrowClockwiseIcon, CaretUpIcon, + DotsThreeVerticalIcon, MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, MonitorIcon, - DotsThreeVerticalIcon, TimerIcon, VideoCameraIcon, VideoCameraSlashIcon, XIcon, - ArrowClockwiseIcon, } from "@phosphor-icons/react"; import { AnimatePresence, motion } from "motion/react"; +import { useEffect, useRef } from "react"; import { RxDragHandleDots2 } from "react-icons/rx"; +import { Separator } from "@/components/ui/separator"; import { useScopedT } from "../../contexts/I18nContext"; -import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useMicrophoneDevices } from "../../hooks/useMicrophoneDevices"; -import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; +import { useScreenRecorder } from "../../hooks/useScreenRecorder"; +import { useVideoDevices } from "../../hooks/useVideoDevices"; +import { Button } from "../ui/button"; +import { HudInteractionContext } from "./contexts/HudInteractionContext"; +import { canToggleFloatingWebcamPreview } from "./floatingWebcamPreview"; +import { useHudBarDrag } from "./hooks/useHudBarDrag"; import { useLaunchHudInteractionState } from "./hooks/useLaunchHudInteractionState"; import { useLaunchWindowActions } from "./hooks/useLaunchWindowActions"; +import { useLaunchWindowSystemState } from "./hooks/useLaunchWindowSystemState"; import { useRecordingTimer } from "./hooks/useRecordingTimer"; -import { useScreenRecorder } from "../../hooks/useScreenRecorder"; -import { useVideoDevices } from "../../hooks/useVideoDevices"; import { useWebcamPreviewOverlay } from "./hooks/useWebcamPreviewOverlay"; -import { - canToggleFloatingWebcamPreview, -} from "./floatingWebcamPreview"; -import { LaunchPopoverCoordinatorProvider, useLaunchPopoverCoordinator } from "./popovers/LaunchPopoverCoordinator"; +import styles from "./LaunchWindow.module.css"; import { CountdownPopover } from "./popovers/CountdownPopover"; +import { + LaunchPopoverCoordinatorProvider, + useLaunchPopoverCoordinator, +} from "./popovers/LaunchPopoverCoordinator"; import { MicPopover } from "./popovers/MicPopover"; import { MorePopover } from "./popovers/MorePopover"; import { ProjectPopover } from "./popovers/ProjectPopover"; import { SourcePopover } from "./popovers/SourcePopover"; import { WebcamPopover } from "./popovers/WebcamPopover"; -import { HudInteractionContext } from "./contexts/HudInteractionContext"; -import { MarqueeText } from "./SourceSelector"; -import styles from "./LaunchWindow.module.css"; - -import { Separator } from "@/components/ui/separator"; -import { Button } from "../ui/button"; import { RecordingControls } from "./RecordingControls"; -import { useEffect, useRef } from "react"; +import { MarqueeText } from "./SourceSelector"; const SHOW_DEV_UPDATE_PREVIEW = import.meta.env.DEV; @@ -84,7 +84,6 @@ function LaunchWindowContent() { const hudContentRef = useRef(null); const hudBarRef = useRef(null); - const { selectedSource, hasSelectedSource, @@ -167,12 +166,13 @@ function LaunchWindowContent() { recordingWebcamPreviewContainerRef, }); - const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = useLaunchHudInteractionState({ - openId, - isHudDraggingRef, - isWebcamPreviewDraggingRef, - webcamPreviewDragStartRef, - }); + const { handleHudMouseEnter, handleHudMouseLeave, beginInteractiveHudAction } = + useLaunchHudInteractionState({ + openId, + isHudDraggingRef, + isWebcamPreviewDraggingRef, + webcamPreviewDragStartRef, + }); useEffect(() => { let mounted = true; @@ -196,7 +196,6 @@ function LaunchWindowContent() { ease: [0.22, 1, 0.36, 1] as const, }; - const recordingControls = ( - {microphoneEnabled ? : } + {microphoneEnabled ? ( + + ) : ( + + )} } /> @@ -283,9 +286,7 @@ function LaunchWindowContent() { hudOverlayMousePassthroughSupported, )} showFloatingWebcamPreview={showFloatingWebcamPreview} - onToggleFloatingPreview={() => - setShowFloatingWebcamPreview((current) => !current) - } + onToggleFloatingPreview={() => setShowFloatingWebcamPreview((current) => !current)} showWebcamControls={showWebcamControls} setWebcamPreviewNode={setWebcamPreviewNode} videoDevices={videoDevices} @@ -308,7 +309,11 @@ function LaunchWindowContent() { } className={webcamEnabled ? styles.ibActive : ""} > - {webcamEnabled ? : } + {webcamEnabled ? ( + + ) : ( + + )} } /> @@ -329,7 +334,6 @@ function LaunchWindowContent() { } /> - } @@ -429,116 +428,117 @@ function LaunchWindowContent() { const hudMode = finalizing ? "finalizing" : recording ? "recording" : "idle"; return ( - +
-
- -
- -
- -
- - - {finalizing - ? finalizingControls - : recording - ? recordingControls - : idleControls} - - -
-
-
- {showRecordingWebcamPreview && (
-
- )} + {showRecordingWebcamPreview && ( +
+
+ )} +
-
-
); } diff --git a/src/components/launch/RecordingControls.tsx b/src/components/launch/RecordingControls.tsx index 4e24f943a..49bc5d324 100644 --- a/src/components/launch/RecordingControls.tsx +++ b/src/components/launch/RecordingControls.tsx @@ -1,8 +1,16 @@ -import { MicrophoneIcon, MicrophoneSlashIcon, MinusIcon, PauseIcon, PlayIcon, SquareIcon, XIcon } from "@phosphor-icons/react"; +import { + MicrophoneIcon, + MicrophoneSlashIcon, + MinusIcon, + PauseIcon, + PlayIcon, + SquareIcon, + XIcon, +} from "@phosphor-icons/react"; import { useMemo } from "react"; -import { useScopedT } from "@/contexts/I18nContext"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { useScopedT } from "@/contexts/I18nContext"; import styles from "./LaunchWindow.module.css"; interface RecordingControlsProps { diff --git a/src/components/launch/SourceSelector.tsx b/src/components/launch/SourceSelector.tsx index 158b53e16..6f3e51d2c 100644 --- a/src/components/launch/SourceSelector.tsx +++ b/src/components/launch/SourceSelector.tsx @@ -1,15 +1,15 @@ +import { AppWindowIcon, CaretUpIcon, MonitorIcon } from "@phosphor-icons/react"; import * as React from "react"; -import { MonitorIcon, AppWindowIcon, CaretUpIcon } from "@phosphor-icons/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { useScopedT } from "@/contexts/I18nContext"; -import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Button } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { useScopedT } from "@/contexts/I18nContext"; import { cn } from "@/lib/utils"; import { - mapRawSource, + type DesktopSource, isScreenSource, isWindowSource, - type DesktopSource, + mapRawSource, } from "./popovers/launchPopoverTypes"; import "./launchTheme.css"; import "./SourceSelector.css"; @@ -81,7 +81,10 @@ export const SourceSelectorContent = ({ selectedSource = "Screen", loading = false, onSourceSelect = () => {}, -}: Pick) => { +}: Pick< + SourceSelectorProps, + "screenSources" | "windowSources" | "selectedSource" | "loading" | "onSourceSelect" +>) => { const t = useScopedT("launch"); const renderSourceItem = (source: DesktopSource, index: number) => { const isSelected = selectedSource === source.name; @@ -116,12 +119,14 @@ export const SourceSelectorContent = ({ )} -
+
- {source.sourceType === "screen" ? t("recording.screen") : t("recording.window")} + {source.sourceType === "screen" + ? t("recording.screen") + : t("recording.window")}
@@ -156,7 +161,9 @@ export const SourceSelectorContent = ({
- {screenSources.map((source, index) => renderSourceItem(source, index))} + {screenSources.map((source, index) => + renderSourceItem(source, index), + )}
) : null} @@ -166,7 +173,9 @@ export const SourceSelectorContent = ({ {t("recording.windows")}
- {windowSources.map((source, index) => renderSourceItem(source, index))} + {windowSources.map((source, index) => + renderSourceItem(source, index), + )}
) : null} diff --git a/src/components/launch/hooks/useHudBarDrag.ts b/src/components/launch/hooks/useHudBarDrag.ts index 92c294cb1..82a26c3f6 100644 --- a/src/components/launch/hooks/useHudBarDrag.ts +++ b/src/components/launch/hooks/useHudBarDrag.ts @@ -1,5 +1,8 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent, type RefObject } from "react"; -import { mergeHudInteractiveBounds, shouldRestoreHudMousePassthroughAfterDrag } from "../hudMousePassthrough"; +import { type PointerEvent, type RefObject, useCallback, useEffect, useRef, useState } from "react"; +import { + mergeHudInteractiveBounds, + shouldRestoreHudMousePassthroughAfterDrag, +} from "../hudMousePassthrough"; const DEFAULT_RECORDING_HUD_OFFSET = { x: 0, y: 0 }; @@ -16,20 +19,17 @@ export function useHudBarDrag({ const [isHudDragging, setIsHudDragging] = useState(false); const hudBarTransformRef = useRef(null); const recordingHudOffsetRef = useRef(DEFAULT_RECORDING_HUD_OFFSET); - const hudDragStartRef = useRef< - | { - pointerId: number; - startX: number; - startY: number; - originX: number; - originY: number; - initialLeft: number; - initialTop: number; - hudWidth: number; - hudHeight: number; - } - | null - >(null); + const hudDragStartRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + originX: number; + originY: number; + initialLeft: number; + initialTop: number; + hudWidth: number; + hudHeight: number; + } | null>(null); const isHudDraggingRef = useRef(false); const hudDragMoveRafRef = useRef(null); const hudDragPendingPointerRef = useRef<{ clientX: number; clientY: number } | null>(null); @@ -41,32 +41,35 @@ export function useHudBarDrag({ } }, [recordingHudOffset]); - const handleHudBarPointerDown = useCallback((event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleHudBarPointerDown = useCallback( + (event: PointerEvent) => { + if (event.button !== 0) { + return; + } - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - isHudDraggingRef.current = true; - setIsHudDragging(true); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - if (!hudBarRef.current) { - return; - } - const hudRect = hudBarRef.current.getBoundingClientRect(); - hudDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: recordingHudOffsetRef.current.x, - originY: recordingHudOffsetRef.current.y, - initialLeft: hudRect.left, - initialTop: hudRect.top, - hudWidth: hudRect.width, - hudHeight: hudRect.height, - }; - }, [hudBarRef]); + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + isHudDraggingRef.current = true; + setIsHudDragging(true); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + if (!hudBarRef.current) { + return; + } + const hudRect = hudBarRef.current.getBoundingClientRect(); + hudDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: recordingHudOffsetRef.current.x, + originY: recordingHudOffsetRef.current.y, + initialLeft: hudRect.left, + initialTop: hudRect.top, + hudWidth: hudRect.width, + hudHeight: hudRect.height, + }; + }, + [hudBarRef], + ); const handleHudBarPointerMove = useCallback((event: PointerEvent) => { const dragState = hudDragStartRef.current; @@ -113,66 +116,75 @@ export function useHudBarDrag({ }); }, []); - const handleHudBarPointerUp = useCallback((event: PointerEvent) => { - const dragState = hudDragStartRef.current; - if (!dragState || dragState.pointerId !== event.pointerId) { - return; - } + const handleHudBarPointerUp = useCallback( + (event: PointerEvent) => { + const dragState = hudDragStartRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) { + return; + } - const pointer = hudDragPendingPointerRef.current || { clientX: event.clientX, clientY: event.clientY }; - const deltaX = pointer.clientX - dragState.startX; - const deltaY = pointer.clientY - dragState.startY; - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const clampedLeft = Math.min( - Math.max(0, dragState.initialLeft + deltaX), - Math.max(0, viewportWidth - dragState.hudWidth), - ); - const clampedTop = Math.min( - Math.max(0, dragState.initialTop + deltaY), - Math.max(0, viewportHeight - dragState.hudHeight), - ); - - recordingHudOffsetRef.current = { - x: dragState.originX + (clampedLeft - dragState.initialLeft), - y: dragState.originY + (clampedTop - dragState.initialTop), - }; + const pointer = hudDragPendingPointerRef.current || { + clientX: event.clientX, + clientY: event.clientY, + }; + const deltaX = pointer.clientX - dragState.startX; + const deltaY = pointer.clientY - dragState.startY; + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; - if (hudDragMoveRafRef.current !== null) { - cancelAnimationFrame(hudDragMoveRafRef.current); - hudDragMoveRafRef.current = null; - } - hudDragPendingPointerRef.current = null; - - hudDragStartRef.current = null; - const wasDragging = isHudDraggingRef.current; - isHudDraggingRef.current = false; - setRecordingHudOffset({ ...recordingHudOffsetRef.current }); - setIsHudDragging(false); - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - const hudBounds = mergeHudInteractiveBounds( - [ - hudContentRef.current?.getBoundingClientRect(), - hudBarRef.current?.getBoundingClientRect(), - recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), - ].map((bounds) => - bounds - ? { - left: bounds.left, - top: bounds.top, - right: bounds.right, - bottom: bounds.bottom, - } - : null, - ), - ); - if (wasDragging && shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY)) { - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef]); + const clampedLeft = Math.min( + Math.max(0, dragState.initialLeft + deltaX), + Math.max(0, viewportWidth - dragState.hudWidth), + ); + const clampedTop = Math.min( + Math.max(0, dragState.initialTop + deltaY), + Math.max(0, viewportHeight - dragState.hudHeight), + ); + + recordingHudOffsetRef.current = { + x: dragState.originX + (clampedLeft - dragState.initialLeft), + y: dragState.originY + (clampedTop - dragState.initialTop), + }; + + if (hudDragMoveRafRef.current !== null) { + cancelAnimationFrame(hudDragMoveRafRef.current); + hudDragMoveRafRef.current = null; + } + hudDragPendingPointerRef.current = null; + + hudDragStartRef.current = null; + const wasDragging = isHudDraggingRef.current; + isHudDraggingRef.current = false; + setRecordingHudOffset({ ...recordingHudOffsetRef.current }); + setIsHudDragging(false); + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + const hudBounds = mergeHudInteractiveBounds( + [ + hudContentRef.current?.getBoundingClientRect(), + hudBarRef.current?.getBoundingClientRect(), + recordingWebcamPreviewContainerRef.current?.getBoundingClientRect(), + ].map((bounds) => + bounds + ? { + left: bounds.left, + top: bounds.top, + right: bounds.right, + bottom: bounds.bottom, + } + : null, + ), + ); + if ( + wasDragging && + shouldRestoreHudMousePassthroughAfterDrag(hudBounds, event.clientX, event.clientY) + ) { + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, + [hudBarRef, hudContentRef, recordingWebcamPreviewContainerRef], + ); useEffect(() => { return () => { diff --git a/src/components/launch/hooks/useLaunchHudInteractionState.ts b/src/components/launch/hooks/useLaunchHudInteractionState.ts index 3b1fd51e6..54ae3e25f 100644 --- a/src/components/launch/hooks/useLaunchHudInteractionState.ts +++ b/src/components/launch/hooks/useLaunchHudInteractionState.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, type MouseEvent, type RefObject } from "react"; +import { type MouseEvent, type RefObject, useCallback, useEffect, useRef } from "react"; export function useLaunchHudInteractionState({ openId, @@ -41,30 +41,33 @@ export function useLaunchHudInteractionState({ const timeoutRef = useRef(null); - const handleHudMouseLeave = useCallback((event: MouseEvent) => { - const nextTarget = event.relatedTarget; - if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { - return; - } + const handleHudMouseLeave = useCallback( + (event: MouseEvent) => { + const nextTarget = event.relatedTarget; + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return; + } - isMouseOverHudRef.current = false; + isMouseOverHudRef.current = false; - if (timeoutRef.current) clearTimeout(timeoutRef.current); + if (timeoutRef.current) clearTimeout(timeoutRef.current); - timeoutRef.current = setTimeout(() => { - if ( - !isHudDraggingRef.current && - !isWebcamPreviewDraggingRef.current && - !webcamPreviewDragStartRef.current && - !isMouseOverHudRef.current && - !anyPopoverOpenRef.current - ) { - // If a popover is open, we can still ignore mouse if the mouse is truly gone, - // but we give a bit more breathing room (the 300ms timeout). - window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); - } - }, 300); - }, [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef]); + timeoutRef.current = setTimeout(() => { + if ( + !isHudDraggingRef.current && + !isWebcamPreviewDraggingRef.current && + !webcamPreviewDragStartRef.current && + !isMouseOverHudRef.current && + !anyPopoverOpenRef.current + ) { + // If a popover is open, we can still ignore mouse if the mouse is truly gone, + // but we give a bit more breathing room (the 300ms timeout). + window.electronAPI?.hudOverlaySetIgnoreMouse?.(true); + } + }, 300); + }, + [isHudDraggingRef, isWebcamPreviewDraggingRef, webcamPreviewDragStartRef], + ); return { handleHudMouseEnter, diff --git a/src/components/launch/hooks/useWebcamPreviewOverlay.ts b/src/components/launch/hooks/useWebcamPreviewOverlay.ts index 7c93899a0..9027276c3 100644 --- a/src/components/launch/hooks/useWebcamPreviewOverlay.ts +++ b/src/components/launch/hooks/useWebcamPreviewOverlay.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; +import { type PointerEvent, useCallback, useEffect, useRef, useState } from "react"; import { canShowFloatingWebcamPreview } from "../floatingWebcamPreview"; const WEBCAM_PREVIEW_DRAG_THRESHOLD = 6; @@ -61,32 +61,29 @@ export function useWebcamPreviewOverlay({ } }, [webcamEnabled]); - const handleWebcamPreviewPointerDown = useCallback( - (event: PointerEvent) => { - if (event.button !== 0) { - return; - } + const handleWebcamPreviewPointerDown = useCallback((event: PointerEvent) => { + if (event.button !== 0) { + return; + } - const previewRect = event.currentTarget.getBoundingClientRect(); + const previewRect = event.currentTarget.getBoundingClientRect(); - event.preventDefault(); - window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); - webcamPreviewDragStartRef.current = { - pointerId: event.pointerId, - startX: event.clientX, - startY: event.clientY, - originX: webcamPreviewOffsetRef.current.x, - originY: webcamPreviewOffsetRef.current.y, - initialLeft: previewRect.left, - initialTop: previewRect.top, - previewWidth: previewRect.width, - previewHeight: previewRect.height, - dragging: false, - }; - event.currentTarget.setPointerCapture(event.pointerId); - }, - [], - ); + event.preventDefault(); + window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); + webcamPreviewDragStartRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + originX: webcamPreviewOffsetRef.current.x, + originY: webcamPreviewOffsetRef.current.y, + initialLeft: previewRect.left, + initialTop: previewRect.top, + previewWidth: previewRect.width, + previewHeight: previewRect.height, + dragging: false, + }; + event.currentTarget.setPointerCapture(event.pointerId); + }, []); const handleWebcamPreviewPointerMove = useCallback((event: PointerEvent) => { const dragState = webcamPreviewDragStartRef.current; @@ -225,12 +222,12 @@ export function useWebcamPreviewOverlay({ width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - } + } : { width: { ideal: 320 }, height: { ideal: 320 }, frameRate: { ideal: 24, max: 30 }, - }, + }, audio: false, }); diff --git a/src/components/launch/popovers/CountdownPopover.tsx b/src/components/launch/popovers/CountdownPopover.tsx index a0c4b6698..94d0c352b 100644 --- a/src/components/launch/popovers/CountdownPopover.tsx +++ b/src/components/launch/popovers/CountdownPopover.tsx @@ -2,8 +2,8 @@ import { TimerIcon } from "@phosphor-icons/react"; import type { ReactElement } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import styles from "../LaunchWindow.module.css"; -import { DropdownItem, HudPopover } from "./PopoverScaffold"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; const POPOVER_ID = "countdown"; const COUNTDOWN_OPTIONS = [0, 3, 5, 10]; diff --git a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx index ead6cdc45..7d132721e 100644 --- a/src/components/launch/popovers/LaunchPopoverCoordinator.tsx +++ b/src/components/launch/popovers/LaunchPopoverCoordinator.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; interface LaunchPopoverCoordinatorValue { openId: string | null; @@ -42,7 +42,9 @@ export function LaunchPopoverCoordinatorProvider({ children }: { children: React export function useLaunchPopoverCoordinator() { const context = useContext(LaunchPopoverCoordinatorContext); if (!context) { - throw new Error("useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider"); + throw new Error( + "useLaunchPopoverCoordinator must be used within LaunchPopoverCoordinatorProvider", + ); } return context; } diff --git a/src/components/launch/popovers/MicPopover.tsx b/src/components/launch/popovers/MicPopover.tsx index 02247d662..45ccb503e 100644 --- a/src/components/launch/popovers/MicPopover.tsx +++ b/src/components/launch/popovers/MicPopover.tsx @@ -1,10 +1,10 @@ import { MicrophoneSlashIcon, SpeakerHighIcon, SpeakerXIcon } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; import { useScopedT } from "@/contexts/I18nContext"; -import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold"; +import styles from "../LaunchWindow.module.css"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import type { DeviceOption } from "./launchPopoverTypes"; -import type { ReactElement } from "react"; -import styles from "../LaunchWindow.module.css"; +import { DropdownItem, HudPopover, MicDeviceRow } from "./PopoverScaffold"; const POPOVER_ID = "mic"; @@ -53,7 +53,9 @@ export function MicPopover({ >
{t("recording.microphone")}
: } + icon={ + systemAudioEnabled ? : + } selected={systemAudioEnabled} onClick={onToggleSystemAudio} > @@ -73,7 +75,9 @@ export function MicPopover({ )} {!microphoneEnabled && ( -
{t("recording.selectMicToEnable")}
+
+ {t("recording.selectMicToEnable")} +
)} {devices.map((device) => ( onSelectDevice(device.deviceId)} /> ))} {devices.length === 0 && ( -
{t("recording.noMicrophonesFound")}
+
+ {t("recording.noMicrophonesFound")} +
)} ); diff --git a/src/components/launch/popovers/MorePopover.tsx b/src/components/launch/popovers/MorePopover.tsx index 94687308a..4a150ce3f 100644 --- a/src/components/launch/popovers/MorePopover.tsx +++ b/src/components/launch/popovers/MorePopover.tsx @@ -1,17 +1,16 @@ import { + ArrowClockwiseIcon, + DesktopIcon, EyeIcon, EyeSlashIcon, FolderOpenIcon, + MoonIcon, + SunIcon, TranslateIcon, VideoCameraIcon, - ArrowClockwiseIcon, - SunIcon, - MoonIcon, - DesktopIcon, } from "@phosphor-icons/react"; import type { ReactElement } from "react"; -import { useI18n } from "@/contexts/I18nContext"; -import { useScopedT } from "@/contexts/I18nContext"; +import { useI18n, useScopedT } from "@/contexts/I18nContext"; import { useTheme } from "@/contexts/ThemeContext"; import type { AppLocale } from "@/i18n/config"; import { SUPPORTED_LOCALES } from "@/i18n/config"; diff --git a/src/components/launch/popovers/PopoverScaffold.tsx b/src/components/launch/popovers/PopoverScaffold.tsx index 856de855d..256bfaa37 100644 --- a/src/components/launch/popovers/PopoverScaffold.tsx +++ b/src/components/launch/popovers/PopoverScaffold.tsx @@ -1,12 +1,12 @@ import { MicrophoneIcon, MicrophoneSlashIcon } from "@phosphor-icons/react"; import type { ReactElement, ReactNode } from "react"; +import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { useAudioLevelMeter } from "@/hooks/useAudioLevelMeter"; -import { AudioLevelMeter } from "@/components/ui/audio-level-meter"; import styles from "../LaunchWindow.module.css"; import "../launchTheme.css"; -import type { DeviceOption } from "./launchPopoverTypes"; import { useHudInteraction } from "../contexts/HudInteractionContext"; +import type { DeviceOption } from "./launchPopoverTypes"; export function DropdownItem({ onClick, @@ -54,7 +54,9 @@ export function MicDeviceRow({ className={`${styles.ddItem} ${selected ? styles.ddItemSelected : ""}`} onClick={onSelect} > - {selected ? : } + + {selected ? : } + {device.label} diff --git a/src/components/launch/popovers/ProjectPopover.tsx b/src/components/launch/popovers/ProjectPopover.tsx index 22df11d05..024617f20 100644 --- a/src/components/launch/popovers/ProjectPopover.tsx +++ b/src/components/launch/popovers/ProjectPopover.tsx @@ -1,8 +1,8 @@ import type { ReactElement } from "react"; +import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; +import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { HudPopover } from "./PopoverScaffold"; -import ProjectBrowserDialog from "../../video-editor/ProjectBrowserDialog"; -import type { ProjectLibraryEntry } from "../../video-editor/ProjectBrowserDialog"; const POPOVER_ID = "projects"; diff --git a/src/components/launch/popovers/SourcePopover.tsx b/src/components/launch/popovers/SourcePopover.tsx index 5459fae14..5bcacaac7 100644 --- a/src/components/launch/popovers/SourcePopover.tsx +++ b/src/components/launch/popovers/SourcePopover.tsx @@ -1,11 +1,11 @@ -import { useCallback, useMemo, type ReactNode, useState } from "react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; import { SourceSelector } from "../SourceSelector"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import { - mapRawSource, + type DesktopSource, isScreenSource, isWindowSource, - type DesktopSource, + mapRawSource, } from "./launchPopoverTypes"; const POPOVER_ID = "sources"; diff --git a/src/components/launch/popovers/WebcamPopover.tsx b/src/components/launch/popovers/WebcamPopover.tsx index 945ffac61..ae451ffb5 100644 --- a/src/components/launch/popovers/WebcamPopover.tsx +++ b/src/components/launch/popovers/WebcamPopover.tsx @@ -4,11 +4,11 @@ import { VideoCamera as Video, VideoCameraSlash as VideoOff, } from "@phosphor-icons/react"; +import type { ReactElement } from "react"; import { useScopedT } from "@/contexts/I18nContext"; -import { DropdownItem, HudPopover } from "./PopoverScaffold"; import { useLaunchPopoverCoordinator } from "./LaunchPopoverCoordinator"; import type { DeviceOption } from "./launchPopoverTypes"; -import type { ReactElement } from "react"; +import { DropdownItem, HudPopover } from "./PopoverScaffold"; const POPOVER_ID = "webcam"; @@ -66,15 +66,20 @@ export function WebcamPopover({ {webcamEnabled && ( <> - } onClick={() => { - onDisableWebcam(); - requestClose(POPOVER_ID); - }}> + } + onClick={() => { + onDisableWebcam(); + requestClose(POPOVER_ID); + }} + > {t("recording.turnOffWebcam")} {canToggleFloatingPreview ? ( : } + icon={ + showFloatingWebcamPreview ? : + } selected={showFloatingWebcamPreview} onClick={onToggleFloatingPreview} > @@ -86,7 +91,9 @@ export function WebcamPopover({ )} {!webcamEnabled && ( -
{t("recording.selectWebcamToEnable")}
+
+ {t("recording.selectWebcamToEnable")} +
)} {showWebcamControls && (
@@ -106,7 +113,8 @@ export function WebcamPopover({ key={device.deviceId} icon={ webcamEnabled && - (webcamDeviceId === device.deviceId || selectedVideoDeviceId === device.deviceId) ? ( + (webcamDeviceId === device.deviceId || + selectedVideoDeviceId === device.deviceId) ? (
diff --git a/src/components/video-editor/KeyboardShortcutsHelp.tsx b/src/components/video-editor/KeyboardShortcutsHelp.tsx index 4c99ff57b..7086c53b6 100644 --- a/src/components/video-editor/KeyboardShortcutsHelp.tsx +++ b/src/components/video-editor/KeyboardShortcutsHelp.tsx @@ -1,4 +1,4 @@ -import { Gear as Settings2, Question as HelpCircle } from "@phosphor-icons/react"; +import { Question as HelpCircle, Gear as Settings2 } from "@phosphor-icons/react"; import { useEffect, useState } from "react"; import { useScopedT } from "@/contexts/I18nContext"; import { useShortcuts } from "@/contexts/ShortcutsContext"; diff --git a/src/components/video-editor/ProjectBrowserDialog.tsx b/src/components/video-editor/ProjectBrowserDialog.tsx index e9e034fdf..7260e1de8 100644 --- a/src/components/video-editor/ProjectBrowserDialog.tsx +++ b/src/components/video-editor/ProjectBrowserDialog.tsx @@ -172,10 +172,12 @@ export default function ProjectBrowserDialog({ ref={panelRef} role="dialog" aria-label="Projects" - className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150" + className="pointer-events-auto mb-1.5 w-[300px] max-h-[400px] overflow-hidden rounded-[14px] border border-foreground/[0.07] bg-editor-panel/[0.96] text-foreground shadow-[0_12px_32px_rgba(0,0,0,0.22),0_2px_10px_rgba(0,0,0,0.1)] animate-in fade-in-0 duration-150" >
-
Projects
+
+ Projects +
{visibleEntries.length > 0 ? ( @@ -243,7 +245,9 @@ export default function ProjectBrowserDialog({ className="pointer-events-auto fixed w-[min(280px,calc(100vw-24px))] overflow-hidden rounded-2xl border border-foreground/10 bg-editor-surface text-foreground shadow-2xl animate-in fade-in-0 duration-150" >
-
Projects
+
+ Projects +
void openExternalLink(RECORDLY_DISCORD_URL, t("feedback.openFailed", "Failed to open link."))} + onClick={() => + void openExternalLink( + RECORDLY_DISCORD_URL, + t("feedback.openFailed", "Failed to open link."), + ) + } className={APP_HEADER_ICON_BUTTON_CLASS} title={t("common.app.discord", "Join Discord")} aria-label={t("common.app.discord", "Join Discord")} @@ -83,10 +99,14 @@ export function FeedbackDialog() { - {t("feedback.title", "Feedback & contact")} + {" "} + {t("feedback.title", "Feedback & contact")} - {t("feedback.description", "Reach out directly or open an issue if something is broken or missing.")} + {t( + "feedback.description", + "Reach out directly or open an issue if something is broken or missing.", + )}
@@ -96,12 +116,19 @@ export function FeedbackDialog() {

{t("feedback.emailLabel", "Email")}

-

{CONTACT_EMAIL}

+

+ {CONTACT_EMAIL} +

@@ -260,8 +310,11 @@ export function TutorialHelp() {

{t("tutorial.descriptionP1")} - {t("tutorial.descriptionRemove")}.{" "} - {t("tutorial.descriptionP3")} + + {" "} + {t("tutorial.descriptionRemove")} + + . {t("tutorial.descriptionP3")}

{/* Visual Illustration */} @@ -340,12 +393,20 @@ export function TutorialHelp() { {/* Steps */}
-
{t("tutorial.addTrimStep")}
-

{t("tutorial.addTrimDesc")}

+
+ {t("tutorial.addTrimStep")} +
+

+ {t("tutorial.addTrimDesc")} +

-
{t("tutorial.adjustStep")}
-

{t("tutorial.adjustDesc")}

+
+ {t("tutorial.adjustStep")} +
+

+ {t("tutorial.adjustDesc")} +

diff --git a/src/components/video-editor/VideoEditor.tsx b/src/components/video-editor/VideoEditor.tsx index fa5fc5acd..6076cc5a5 100644 --- a/src/components/video-editor/VideoEditor.tsx +++ b/src/components/video-editor/VideoEditor.tsx @@ -97,10 +97,7 @@ import { getAspectRatioValue, } from "@/utils/aspectRatioUtils"; import { ExtensionIcon } from "./ExtensionIcon"; -import { - calculateMp4ExportDimensions, - calculateMp4SourceDimensions, -} from "./exportDimensions"; +import { calculateMp4ExportDimensions, calculateMp4SourceDimensions } from "./exportDimensions"; const PhCursorFill = (props: { className?: string; weight?: "fill" | "regular" }) => ( @@ -670,6 +667,7 @@ export default function VideoEditor() { const [cursorTelemetrySourcePath, setCursorTelemetrySourcePath] = useState(null); const [selectedZoomId, setSelectedZoomId] = useState(null); const [trimRegions, setTrimRegions] = useState([]); + const [selectedTrimId, setSelectedTrimId] = useState(null); const [clipRegions, setClipRegions] = useState([]); const [selectedClipId, setSelectedClipId] = useState(null); const [speedRegions, setSpeedRegions] = useState([]); @@ -767,6 +765,7 @@ export default function VideoEditor() { const projectBrowserFallbackTriggerRef = useRef(null); const projectNameInputRef = useRef(null); const nextZoomIdRef = useRef(1); + const nextTrimIdRef = useRef(1); const nextClipIdRef = useRef(1); const nextAudioIdRef = useRef(1); @@ -3397,21 +3396,27 @@ export default function VideoEditor() { setAutoSuggestZoomsTrigger(0); }, []); - const handleSeek = useCallback((time: number, options: { pause?: boolean } = {}) => { - const playback = videoPlaybackRef.current; - const video = playback?.video; - if (!video) return; + const handleSeek = useCallback( + (time: number, options: { pause?: boolean } = {}) => { + const playback = videoPlaybackRef.current; + const video = playback?.video; + if (!video) return; - if (options.pause && !video.paused) { - playback?.pause(); - } + if (options.pause && !video.paused) { + playback?.pause(); + } - video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; - }, [mapTimelineTimeToSourceTime]); + video.currentTime = mapTimelineTimeToSourceTime(time * 1000) / 1000; + }, + [mapTimelineTimeToSourceTime], + ); - const handleTimelineSeek = useCallback((time: number) => { - handleSeek(time, { pause: true }); - }, [handleSeek]); + const handleTimelineSeek = useCallback( + (time: number) => { + handleSeek(time, { pause: true }); + }, + [handleSeek], + ); const handleSelectZoom = useCallback((id: string | null) => { setSelectedZoomId(id); @@ -3557,6 +3562,20 @@ export default function VideoEditor() { ); }, []); + const handleTrimSpanChange = useCallback((id: string, span: Span) => { + setTrimRegions((prev) => + prev.map((region) => + region.id === id + ? { + ...region, + startMs: Math.round(span.start), + endMs: Math.round(span.end), + } + : region, + ), + ); + }, []); + const handleZoomFocusChange = useCallback((id: string, focus: ZoomFocus) => { setZoomRegions((prev) => prev.map((region) => @@ -3609,6 +3628,26 @@ export default function VideoEditor() { [selectedZoomId], ); + const handleSelectTrim = useCallback((id: string | null) => { + setSelectedTrimId(id); + if (id) { + setSelectedZoomId(null); + setSelectedClipId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + } + }, []); + + const handleTrimDelete = useCallback( + (id: string) => { + setTrimRegions((prev) => prev.filter((region) => region.id !== id)); + if (selectedTrimId === id) { + setSelectedTrimId(null); + } + }, + [selectedTrimId], + ); + const handleSelectClip = useCallback((id: string | null) => { setSelectedClipId(id); if (id) { @@ -3787,6 +3826,21 @@ export default function VideoEditor() { [clipRegions, selectedClipId], ); + const handleTrimAdded = useCallback((trimStartMs: number, trimDurationMs: number) => { + const id = `trim-${nextTrimIdRef.current++}`; + const newRegion: TrimRegion = { + id, + startMs: Math.round(trimStartMs), + endMs: Math.round(trimStartMs + trimDurationMs), + }; + setTrimRegions((prev) => [...prev, newRegion]); + setSelectedTrimId(id); + setSelectedZoomId(null); + setSelectedClipId(null); + setSelectedAnnotationId(null); + setSelectedAudioId(null); + }, []); + const handleSelectAudio = useCallback((id: string | null) => { setSelectedAudioId(id); if (id) { @@ -6428,6 +6482,15 @@ export default function VideoEditor() { > + - + - - -
- - + {ASPECT_RATIOS.map((ratio) => ( - onAspectRatioChange?.(ratio)} className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3"> + onAspectRatioChange?.(ratio)} + className="text-muted-foreground hover:text-foreground hover:bg-foreground/10 cursor-pointer flex items-center justify-between gap-3" + > {getAspectRatioLabel(ratio)} - {aspectRatio === ratio && } + {aspectRatio === ratio && ( + + )} ))}
Custom - onCustomAspectWidthChange(event.target.value.replace(/\D/g, ""))} onKeyDown={onCustomAspectRatioKeyDown} className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" aria-label="Custom aspect width" /> + + onCustomAspectWidthChange(event.target.value.replace(/\D/g, "")) + } + onKeyDown={onCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect width" + /> : - onCustomAspectHeightChange(event.target.value.replace(/\D/g, ""))} onKeyDown={onCustomAspectRatioKeyDown} className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" aria-label="Custom aspect height" /> - - {isCustomAspectRatio(aspectRatio) && } + + onCustomAspectHeightChange( + event.target.value.replace(/\D/g, ""), + ) + } + onKeyDown={onCustomAspectRatioKeyDown} + className="w-12 h-7 rounded border border-foreground/10 bg-foreground/5 px-1.5 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-[#2563EB]" + aria-label="Custom aspect height" + /> + + {isCustomAspectRatio(aspectRatio) && ( + + )}
@@ -133,15 +226,21 @@ export default function TimelineToolbar({
- Side Scroll + + Side Scroll + Pan - {scrollLabels.pan} + + {scrollLabels.pan} + Pan - {scrollLabels.zoom} + + {scrollLabels.zoom} + Zoom
diff --git a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx index 48e29b6f9..e52957b7f 100644 --- a/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx +++ b/src/components/video-editor/timeline/components/viewport/TimelineCanvas.tsx @@ -1,28 +1,17 @@ import { Plus } from "@phosphor-icons/react"; import { useTimelineContext } from "dnd-timeline"; import { + type MouseEvent, + type MouseEventHandler, memo, useCallback, useEffect, useMemo, useRef, useState, - type MouseEvent, - type MouseEventHandler, } from "react"; import { cn } from "@/lib/utils"; -import { - getTimelineContentMinHeightPx, - getTimelineRowsMinHeightPx, - getTimelineViewportStretchFactor, - TIMELINE_AXIS_HEIGHT_PX, -} from "../../timelineLayout"; -import AudioWaveform from "../waveform/AudioWaveform"; -import glassStyles from "../../ItemGlass.module.css"; -import Item from "../../Item"; -import Row from "../../Row"; -import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../../core/constants"; -import type { AudioPeaksData, TimelineRenderItem } from "../../core/timelineTypes"; +import { CLIP_ROW_ID, TRIM_ROW_ID, ZOOM_ROW_ID } from "../../core/constants"; import { getAnnotationTrackIndex, getAnnotationTrackRowId, @@ -31,11 +20,23 @@ import { isAnnotationTrackRowId, isAudioTrackRowId, } from "../../core/rows"; +import type { AudioPeaksData, TimelineRenderItem } from "../../core/timelineTypes"; +import Item from "../../Item"; +import glassStyles from "../../ItemGlass.module.css"; +import Row from "../../Row"; +import { + getTimelineContentMinHeightPx, + getTimelineRowsMinHeightPx, + getTimelineViewportStretchFactor, + TIMELINE_AXIS_HEIGHT_PX, +} from "../../timelineLayout"; import TimelineAxis from "../axis/TimelineAxis"; import ClipMarkerOverlay from "../overlays/ClipMarkerOverlay"; import PlaybackCursor from "../playhead/PlaybackCursor"; +import AudioWaveform from "../waveform/AudioWaveform"; const HINT_CLIP = "Press C to split clip"; +const HINT_TRIM = "Press T to add trim"; const HINT_ANNOTATION = "Press A to add annotation"; const HINT_AUDIO = "Click music icon to add audio"; @@ -46,11 +47,13 @@ interface TimelineCanvasProps { onSeek?: (time: number) => void; canPlaceZoomAtMs?: (startMs: number) => boolean; onSelectZoom?: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; onAddZoomAtMs?: (startMs: number) => void; selectedZoomId: string | null; + selectedTrimId?: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; @@ -92,7 +95,9 @@ function useTimelineHover({ (clientX: number, rect: DOMRect) => { const contentWidth = Math.max(1, rect.width - sidebarWidth); const contentX = - direction === "rtl" ? rect.right - sidebarWidth - clientX : clientX - rect.left - sidebarWidth; + direction === "rtl" + ? rect.right - sidebarWidth - clientX + : clientX - rect.left - sidebarWidth; const clampedX = Math.max(0, Math.min(contentX, contentWidth)); const ratio = clampedX / contentWidth; const nextMs = rangeStart + ratio * visibleDurationMs; @@ -179,7 +184,8 @@ function useTimelineHover({ : Math.max(ghostStartMs, Math.min(videoDurationMs, ghostStartMs + ghostDurationMs)); const ghostStartOffsetPx = ghostStartMs === null ? 0 : valueToPixels(Math.max(0, ghostStartMs - rangeStart)); - const ghostEndOffsetPx = ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart)); + const ghostEndOffsetPx = + ghostEndMs === null ? 0 : valueToPixels(Math.max(0, ghostEndMs - rangeStart)); const ghostWidthPx = Math.max(18, ghostEndOffsetPx - ghostStartOffsetPx); const timelineGhostOffsetPx = timelineHoverMs === null ? 0 : valueToPixels(Math.max(0, timelineHoverMs - rangeStart)); @@ -211,10 +217,12 @@ interface TimelineCanvasRowsProps { videoDurationMs: number; selectAllBlocksActive: boolean; selectedZoomId: string | null; + selectedTrimId?: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; onSelectZoom?: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; @@ -235,10 +243,12 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ videoDurationMs, selectAllBlocksActive, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, onSelectZoom, + onSelectTrim, onSelectClip, onSelectAnnotation, onSelectAudio, @@ -253,9 +263,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ onZoomRowMouseLeave, onZoomRowClick, }: TimelineCanvasRowsProps) { - const { clipItems, zoomItems, annotationRows, audioRows } = useMemo(() => { + const { clipItems, zoomItems, trimItems, annotationRows, audioRows } = useMemo(() => { const nextClipItems: TimelineRenderItem[] = []; const nextZoomItems: TimelineRenderItem[] = []; + const nextTrimItems: TimelineRenderItem[] = []; const annotationBuckets = new Map(); const audioBuckets = new Map(); @@ -268,6 +279,10 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ nextZoomItems.push(item); continue; } + if (item.rowId === TRIM_ROW_ID) { + nextTrimItems.push(item); + continue; + } if (isAnnotationTrackRowId(item.rowId)) { const trackIndex = getAnnotationTrackIndex(item.rowId); const bucket = annotationBuckets.get(trackIndex); @@ -299,6 +314,7 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ return { clipItems: nextClipItems, zoomItems: nextZoomItems, + trimItems: nextTrimItems, annotationRows: annotationRowsSorted, audioRows: audioRowsSorted, }; @@ -338,8 +354,14 @@ const TimelineCanvasRows = memo(function TimelineCanvasRows({ className="absolute top-1/2 -translate-y-1/2 h-[85%] min-h-[22px]" style={ direction === "rtl" - ? { right: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` } - : { left: `${ghostStartOffsetPx}px`, width: `${ghostWidthPx}px` } + ? { + right: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } + : { + left: `${ghostStartOffsetPx}px`, + width: `${ghostWidthPx}px`, + } } >
+ + {trimItems.map((item) => ( + + {item.label} + + ))} + + {annotationRows.map(({ rowId, items: rowItems }, index) => ( - + {rowItems.map((item) => ( ( - + {rowItems.map((item) => ( ) => { - if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) return; + if (e.button !== 0 || !onSeek || videoDurationMs <= 0 || !localTimelineRef.current) + return; if ((e.target as HTMLElement).closest("[data-timeline-item]")) { return; } @@ -538,7 +589,8 @@ export default function TimelineCanvas({ const flushSeek = () => { seekRafRef.current = null; - if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) return; + if (!onSeek || !localTimelineRef.current || pendingSeekClientXRef.current === null) + return; const rect = localTimelineRef.current.getBoundingClientRect(); onSeek(getAbsoluteMsFromClientX(pendingSeekClientXRef.current, rect) / 1000); }; @@ -583,7 +635,7 @@ export default function TimelineCanvas({ if (isAnnotationTrackRowId(item.rowId)) annotationRowIds.add(item.rowId); if (isAudioTrackRowId(item.rowId)) audioRowIds.add(item.rowId); } - return 2 + annotationRowIds.size + audioRowIds.size; + return 3 + annotationRowIds.size + audioRowIds.size; }, [items]); const timelineRowsMinHeightPx = getTimelineRowsMinHeightPx(timelineRowCount); const timelineContentMinHeightPx = getTimelineContentMinHeightPx(timelineRowCount); @@ -640,23 +692,32 @@ export default function TimelineCanvas({
-
+
)} -
+
{ it("clamps item span to timeline bounds and min duration", () => { - expect(clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 0, end: 120 }); - expect(clampSpanToBounds({ start: 4900, end: 7000 }, { totalMs: 5000, minItemDurationMs: 100 })).toEqual({ start: 2900, end: 5000 }); + expect( + clampSpanToBounds({ start: -100, end: 20 }, { totalMs: 5000, minItemDurationMs: 100 }), + ).toEqual({ start: 0, end: 120 }); + expect( + clampSpanToBounds( + { start: 4900, end: 7000 }, + { totalMs: 5000, minItemDurationMs: 100 }, + ), + ).toEqual({ start: 2900, end: 5000 }); }); it("handles zero-duration timelines in span clamping", () => { - expect(clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 })).toEqual({ start: 0, end: 100 }); - expect(clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 })).toEqual({ start: 50, end: 60 }); + expect( + clampSpanToBounds({ start: -10, end: -5 }, { totalMs: 0, minItemDurationMs: 100 }), + ).toEqual({ start: 0, end: 100 }); + expect( + clampSpanToBounds({ start: 50, end: 60 }, { totalMs: 0, minItemDurationMs: 1 }), + ).toEqual({ start: 50, end: 60 }); }); it("clamps visible range for bounded and unbounded timelines", () => { - expect(clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 })).toEqual({ start: 4700, end: 5000 }); - expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual({ start: 0, end: 300 }); + expect( + clampRange({ start: 4900, end: 5200 }, { totalMs: 5000, minVisibleRangeMs: 300 }), + ).toEqual({ start: 4700, end: 5000 }); + expect(clampRange({ start: -20, end: 50 }, { totalMs: 0, minVisibleRangeMs: 300 })).toEqual( + { start: 0, end: 300 }, + ); }); it("resolves siblings by row and active item", () => { expect(getSiblingSpans("b", undefined, BASE_SPANS).map((s) => s.id)).toEqual(["a", "c"]); - expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual(["a", "b", "c"]); + expect(getSiblingSpans("missing", "row-clip", BASE_SPANS).map((s) => s.id)).toEqual([ + "a", + "b", + "c", + ]); expect(getSiblingSpans("missing", undefined, BASE_SPANS)).toEqual([]); }); it("clamps resize against nearest neighbours and min duration", () => { - const resizedRight = clampResizedSpanToNeighbours( - { start: 900, end: 2000 }, - "a", - { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, - ); + const resizedRight = clampResizedSpanToNeighbours({ start: 900, end: 2000 }, "a", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); expect(resizedRight.end).toBe(1500); - const resizedLeft = clampResizedSpanToNeighbours( - { start: 900, end: 2500 }, - "b", - { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, - ); + const resizedLeft = clampResizedSpanToNeighbours({ start: 900, end: 2500 }, "b", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); expect(resizedLeft.start).toBe(1000); }); it("keeps drag unchanged when already inside valid neighbour gap", () => { - const dragged = clampDraggedSpanToNeighbours( - { start: 1400, end: 2400 }, - "b", - "row-clip", - { allRegionSpans: BASE_SPANS, minItemDurationMs: 100, totalMs: 5000 }, - ); + const dragged = clampDraggedSpanToNeighbours({ start: 1400, end: 2400 }, "b", "row-clip", { + allRegionSpans: BASE_SPANS, + minItemDurationMs: 100, + totalMs: 5000, + }); expect(dragged).toEqual({ start: 1400, end: 2400 }); }); @@ -93,22 +111,30 @@ describe("timeline dnd engine", () => { }); it("resolves resize end with overlap fallback semantics", () => { - const result = resolveResizeEnd("a", { start: 900, end: 2200 }, { - totalMs: 5000, - minItemDurationMs: 100, - allRegionSpans: BASE_SPANS, - hasOverlap: (span, id) => id === "a" && span.end > 1500, - }); + const result = resolveResizeEnd( + "a", + { start: 900, end: 2200 }, + { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: (span, id) => id === "a" && span.end > 1500, + }, + ); expect(result).toEqual({ start: 900, end: 1500 }); }); it("returns null when resize still overlaps after neighbour clamp", () => { - const result = resolveResizeEnd("a", { start: 900, end: 2200 }, { - totalMs: 5000, - minItemDurationMs: 100, - allRegionSpans: BASE_SPANS, - hasOverlap: () => true, - }); + const result = resolveResizeEnd( + "a", + { start: 900, end: 2200 }, + { + totalMs: 5000, + minItemDurationMs: 100, + allRegionSpans: BASE_SPANS, + hasOverlap: () => true, + }, + ); expect(result).toBeNull(); }); @@ -129,32 +155,22 @@ describe("timeline dnd engine", () => { }); it("returns null when drag still overlaps after neighbour clamp", () => { - const result = resolveDragEnd( - "b", - { start: 1200, end: 1800 }, - "row-clip", - { - allRegionSpans: BASE_SPANS, - totalMs: 5000, - minItemDurationMs: 100, - hasOverlap: () => true, - }, - ); + const result = resolveDragEnd("b", { start: 1200, end: 1800 }, "row-clip", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => true, + }); expect(result).toBeNull(); }); it("keeps proposed row when no target row resolver is provided", () => { - const result = resolveDragEnd( - "aud-1", - { start: 700, end: 1000 }, - "row-audio-2", - { - allRegionSpans: BASE_SPANS, - totalMs: 5000, - minItemDurationMs: 100, - hasOverlap: () => false, - }, - ); + const result = resolveDragEnd("aud-1", { start: 700, end: 1000 }, "row-audio-2", { + allRegionSpans: BASE_SPANS, + totalMs: 5000, + minItemDurationMs: 100, + hasOverlap: () => false, + }); expect(result?.rowId).toBe("row-audio-2"); }); }); diff --git a/src/components/video-editor/timeline/dnd/engine.ts b/src/components/video-editor/timeline/dnd/engine.ts index 02be45235..30bd598a7 100644 --- a/src/components/video-editor/timeline/dnd/engine.ts +++ b/src/components/video-editor/timeline/dnd/engine.ts @@ -9,7 +9,10 @@ export interface DndEngineConfig { hasOverlap: (newSpan: Span, excludeId?: string, rowId?: string) => boolean; } -export function clampSpanToBounds(span: Span, config: Pick): Span { +export function clampSpanToBounds( + span: Span, + config: Pick, +): Span { const { totalMs, minItemDurationMs } = config; const rawDuration = Math.max(span.end - span.start, 0); const normalizedStart = Number.isFinite(span.start) ? span.start : 0; @@ -27,7 +30,10 @@ export function clampSpanToBounds(span: Span, config: Pick): Range { +export function clampRange( + candidate: Range, + config: Pick, +): Range { const { totalMs, minVisibleRangeMs } = config; if (totalMs === 0) { const minSpan = Math.max(minVisibleRangeMs, 1); @@ -53,7 +59,11 @@ export function clampRange(candidate: Range, config: Pick region.id === activeItemId); const resolvedRowId = rowId ?? activeItem?.rowId; if (!resolvedRowId) { @@ -65,7 +75,11 @@ export function getSiblingSpans(activeItemId: string, rowId: string | undefined, .sort((left, right) => left.start - right.start); } -export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, config: Pick): Span { +export function clampResizedSpanToNeighbours( + span: Span, + activeItemId: string, + config: Pick, +): Span { const { allRegionSpans, minItemDurationMs, totalMs } = config; const siblings = getSiblingSpans(activeItemId, undefined, allRegionSpans); const activeItem = allRegionSpans.find((region) => region.id === activeItemId); @@ -82,7 +96,9 @@ export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, c const minDur = Math.min(minItemDurationMs, totalMs || minItemDurationMs); if (end - start < minDur) { - const resizedLeft = Boolean(activeItem && span.start !== activeItem.start && span.end === activeItem.end); + const resizedLeft = Boolean( + activeItem && span.start !== activeItem.start && span.end === activeItem.end, + ); if (resizedLeft) { start = end - minDur; } else { @@ -93,7 +109,12 @@ export function clampResizedSpanToNeighbours(span: Span, activeItemId: string, c return { start: Math.max(0, start), end: Math.min(end, totalMs || end) }; } -export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, rowId: string | undefined, config: Pick): Span { +export function clampDraggedSpanToNeighbours( + span: Span, + activeItemId: string, + rowId: string | undefined, + config: Pick, +): Span { const { allRegionSpans, minItemDurationMs, totalMs } = config; const activeItem = allRegionSpans.find((region) => region.id === activeItemId); if (!activeItem) { @@ -107,19 +128,33 @@ export function clampDraggedSpanToNeighbours(span: Span, activeItemId: string, r ); const proposedStart = Number.isFinite(span.start) ? span.start : activeItem.start; - const previousSibling = [...siblings].reverse().find((region) => region.end <= activeItem.start); + const previousSibling = [...siblings] + .reverse() + .find((region) => region.end <= activeItem.start); const nextSibling = siblings.find((region) => region.start >= activeItem.end); const minStart = previousSibling ? previousSibling.end : 0; - const maxStart = nextSibling ? nextSibling.start - duration : totalMs > 0 ? totalMs - duration : proposedStart; + const maxStart = nextSibling + ? nextSibling.start - duration + : totalMs > 0 + ? totalMs - duration + : proposedStart; const start = Math.max(minStart, Math.min(proposedStart, maxStart)); return clampSpanToBounds({ start, end: start + duration }, { totalMs, minItemDurationMs }); } -export function resolveResizeEnd(activeItemId: string, updatedSpan: Span, config: Pick): Span | null { +export function resolveResizeEnd( + activeItemId: string, + updatedSpan: Span, + config: Pick< + DndEngineConfig, + "totalMs" | "minItemDurationMs" | "allRegionSpans" | "hasOverlap" + >, +): Span | null { const { totalMs, minItemDurationMs, allRegionSpans, hasOverlap } = config; let clamped = clampSpanToBounds(updatedSpan, { totalMs, minItemDurationMs }); - const effectiveMinDuration = totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; + const effectiveMinDuration = + totalMs > 0 ? Math.min(minItemDurationMs, totalMs) : minItemDurationMs; if (clamped.end - clamped.start < effectiveMinDuration) { return null; } @@ -145,14 +180,19 @@ export function resolveDragEnd( activeItemId: string, updatedSpan: Span, proposedRowId: string, - config: Pick, + config: Pick< + DndEngineConfig, + "allRegionSpans" | "totalMs" | "minItemDurationMs" | "hasOverlap" + >, resolveTargetRowId?: (id: string, proposedRowId: string) => string, ): { span: Span; rowId: string } | null { const { allRegionSpans, totalMs, minItemDurationMs, hasOverlap } = config; const resolvedRowId = resolveTargetRowId?.(activeItemId, proposedRowId) ?? proposedRowId; const activeItem = allRegionSpans.find((r) => r.id === activeItemId); - const originalDuration = activeItem ? activeItem.end - activeItem.start : updatedSpan.end - updatedSpan.start; + const originalDuration = activeItem + ? activeItem.end - activeItem.start + : updatedSpan.end - updatedSpan.start; const dragSpan: Span = { start: updatedSpan.start, end: updatedSpan.start + originalDuration }; let clamped = clampSpanToBounds(dragSpan, { totalMs, minItemDurationMs }); diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts index b0e1fff60..63dc36eb2 100644 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineAudioActions.ts @@ -24,7 +24,11 @@ interface UseTimelineAudioActionsParams { regions: { audio: TimelineAudioRegion[]; }; - onAudioAdded?: (span: { start: number; end: number }, audioPath: string, trackIndex?: number) => void; + onAudioAdded?: ( + span: { start: number; end: number }, + audioPath: string, + trackIndex?: number, + ) => void; deps?: Partial; } diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.test.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.test.ts new file mode 100644 index 000000000..367ffb281 --- /dev/null +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, vi } from "vitest"; + +/** + * Since useTimelineTrimActions is a React hook that requires a component + * context, and @testing-library/react is not installed, we test the + * underlying trim placement logic directly via pure functions that mirror + * the hook's internal logic. + */ + +interface TrimRegion { + id: string; + startMs: number; + endMs: number; +} + +/** Mirrors the core placement logic from useTimelineTrimActions */ +function computeTrimPlacement(params: { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + trimRegions: TrimRegion[]; + defaultTrimDurationMs: number; +}): { startMs: number; durationMs: number } | null { + const { videoDuration, totalMs, currentTimeMs, trimRegions, defaultTrimDurationMs } = params; + + if (!videoDuration || videoDuration === 0 || totalMs === 0) return null; + if (defaultTrimDurationMs <= 0) return null; + + const isOverlapping = trimRegions.some( + (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, + ); + if (isOverlapping) return null; + + const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > currentTimeMs); + const gapToNext = nextRegion ? nextRegion.startMs - currentTimeMs : totalMs - currentTimeMs; + + if (gapToNext <= 0) return null; + + const actualDuration = Math.min(defaultTrimDurationMs, gapToNext); + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + + return { startMs: Math.round(startPos), durationMs: Math.round(actualDuration) }; +} + +describe("trim placement logic", () => { + it("returns null when video has no duration", () => { + expect( + computeTrimPlacement({ + videoDuration: 0, + totalMs: 0, + currentTimeMs: 0, + trimRegions: [], + defaultTrimDurationMs: 2000, + }), + ).toBeNull(); + }); + + it("places trim at playhead when no existing trims", () => { + const result = computeTrimPlacement({ + videoDuration: 10, + totalMs: 10000, + currentTimeMs: 3000, + trimRegions: [], + defaultTrimDurationMs: 2000, + }); + expect(result).toEqual({ startMs: 3000, durationMs: 2000 }); + }); + + it("prevents overlapping trims", () => { + const result = computeTrimPlacement({ + videoDuration: 10, + totalMs: 10000, + currentTimeMs: 4000, + trimRegions: [{ id: "trim-1", startMs: 3000, endMs: 5000 }], + defaultTrimDurationMs: 2000, + }); + expect(result).toBeNull(); + }); + + it("clamps trim duration to gap before next trim", () => { + const result = computeTrimPlacement({ + videoDuration: 10, + totalMs: 10000, + currentTimeMs: 5000, + trimRegions: [{ id: "trim-1", startMs: 6000, endMs: 8000 }], + defaultTrimDurationMs: 2000, + }); + expect(result).toEqual({ startMs: 5000, durationMs: 1000 }); + }); + + it("uses shorter duration for short videos", () => { + const result = computeTrimPlacement({ + videoDuration: 1, + totalMs: 1000, + currentTimeMs: 0, + trimRegions: [], + defaultTrimDurationMs: 1000, + }); + expect(result).toEqual({ startMs: 0, durationMs: 1000 }); + }); + + it("clamps start position to video bounds", () => { + const result = computeTrimPlacement({ + videoDuration: 5, + totalMs: 5000, + currentTimeMs: 4000, + trimRegions: [], + defaultTrimDurationMs: 2000, + }); + // Duration should be clamped to remaining gap (1000ms) + expect(result).toEqual({ startMs: 4000, durationMs: 1000 }); + }); + + it("returns null when playhead is at end with no gap", () => { + const result = computeTrimPlacement({ + videoDuration: 10, + totalMs: 10000, + currentTimeMs: 10000, + trimRegions: [], + defaultTrimDurationMs: 2000, + }); + // Gap is 0 at the end + expect(result).toBeNull(); + }); + + it("places trim between two existing trims", () => { + const result = computeTrimPlacement({ + videoDuration: 20, + totalMs: 20000, + currentTimeMs: 10000, + trimRegions: [ + { id: "t1", startMs: 2000, endMs: 5000 }, + { id: "t2", startMs: 15000, endMs: 18000 }, + ], + defaultTrimDurationMs: 2000, + }); + expect(result).toEqual({ startMs: 10000, durationMs: 2000 }); + }); +}); diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.ts new file mode 100644 index 000000000..2b18ca66d --- /dev/null +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineTrimActions.ts @@ -0,0 +1,68 @@ +import { useCallback, useMemo } from "react"; +import type { TrimRegion } from "../../../types"; +import { timelineNotifications } from "../utils/timelineNotifications"; + +interface UseTimelineTrimActionsParams { + timeline: { + videoDuration: number; + totalMs: number; + currentTimeMs: number; + }; + regions: { + trim: TrimRegion[]; + }; + onTrimAdded?: (splitMs: number, trimDurationMs: number) => void; +} + +export function useTimelineTrimActions({ + timeline, + regions, + onTrimAdded, +}: UseTimelineTrimActionsParams) { + const { videoDuration, totalMs, currentTimeMs } = timeline; + const { trim: trimRegions } = regions; + const defaultTrimDurationMs = useMemo(() => Math.min(2000, totalMs), [totalMs]); + + const handleAddTrim = useCallback(() => { + if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onTrimAdded) { + return; + } + + if (defaultTrimDurationMs <= 0) { + return; + } + + // Check if playhead is inside any existing trim region + const isOverlapping = trimRegions.some( + (region) => currentTimeMs >= region.startMs && currentTimeMs < region.endMs, + ); + + if (isOverlapping) { + timelineNotifications.error( + "Cannot place trim here", + "Trim already exists at this location or not enough space available.", + ); + return; + } + + // Find the next trim region after the playhead + const sorted = [...trimRegions].sort((a, b) => a.startMs - b.startMs); + const nextRegion = sorted.find((region) => region.startMs > currentTimeMs); + const gapToNext = nextRegion ? nextRegion.startMs - currentTimeMs : totalMs - currentTimeMs; + + if (gapToNext <= 0) { + timelineNotifications.error( + "Cannot place trim here", + "Trim already exists at this location or not enough space available.", + ); + return; + } + + const actualDuration = Math.min(defaultTrimDurationMs, gapToNext); + const startPos = Math.max(0, Math.min(currentTimeMs, totalMs)); + + onTrimAdded(Math.round(startPos), Math.round(actualDuration)); + }, [videoDuration, totalMs, currentTimeMs, defaultTrimDurationMs, trimRegions, onTrimAdded]); + + return { handleAddTrim, defaultTrimDurationMs }; +} diff --git a/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts index 0981ffe83..4f85751e3 100644 --- a/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts +++ b/src/components/video-editor/timeline/hooks/actions/useTimelineZoomActions.ts @@ -113,7 +113,9 @@ export function useTimelineZoomActions({ } if (disableSuggestedZooms) { - timelineNotifications.info("Suggested zooms are unavailable while cursor looping is enabled."); + timelineNotifications.info( + "Suggested zooms are unavailable while cursor looping is enabled.", + ); return; } diff --git a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts index c49fc338c..d49ec2ecb 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineDndBindings.ts @@ -8,9 +8,14 @@ import type { TrimRegion, ZoomRegion, } from "../../types"; -import type { TimelineRenderItem } from "../core/timelineTypes"; -import { getAnnotationTrackIndex, getAudioTrackIndex, isAnnotationTrackRowId, isAudioTrackRowId } from "../core/rows"; +import { + getAnnotationTrackIndex, + getAudioTrackIndex, + isAnnotationTrackRowId, + isAudioTrackRowId, +} from "../core/rows"; import { spansOverlap } from "../core/spans"; +import type { TimelineRenderItem } from "../core/timelineTypes"; import { buildAllRegionSpans, buildTimelineItems, resolveDropRowId } from "../model/timelineModel"; interface UseTimelineDndBindingsParams { @@ -115,21 +120,23 @@ export function useTimelineDndBindings({ () => buildTimelineItems({ zoomRegions, + trimRegions, clipRegions, annotationRegions, audioRegions, }), - [zoomRegions, clipRegions, annotationRegions, audioRegions], + [zoomRegions, trimRegions, clipRegions, annotationRegions, audioRegions], ); const allRegionSpans = useMemo( () => buildAllRegionSpans({ zoomRegions, + trimRegions, clipRegions, audioRegions, }), - [zoomRegions, clipRegions, audioRegions], + [zoomRegions, trimRegions, clipRegions, audioRegions], ); const getResolvedDropRowId = useCallback( diff --git a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts index 15233f86e..e1caea593 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineEditorRuntime.ts @@ -1,13 +1,6 @@ import type { Span } from "dnd-timeline"; -import { useCallback, useImperativeHandle } from "react"; import type { ForwardedRef, RefObject } from "react"; -import type { TimelineShortcutBindings } from "../core/timelineTypes"; -import { useTimelineDndBindings } from "./useTimelineDndBindings"; -import { useTimelineAudioActions } from "./actions/useTimelineAudioActions"; -import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; -import { useTimelineNormalization } from "./useTimelineNormalization"; -import { useTimelineSelection } from "./useTimelineSelection"; -import { useTimelineZoomActions } from "./actions/useTimelineZoomActions"; +import { useCallback, useImperativeHandle } from "react"; import type { AnnotationRegion, AudioRegion, @@ -18,7 +11,15 @@ import type { ZoomFocus, ZoomRegion, } from "../../types"; +import type { TimelineShortcutBindings } from "../core/timelineTypes"; import type { TimelineEditorHandle } from "../TimelineEditor"; +import { useTimelineAudioActions } from "./actions/useTimelineAudioActions"; +import { useTimelineTrimActions } from "./actions/useTimelineTrimActions"; +import { useTimelineZoomActions } from "./actions/useTimelineZoomActions"; +import { useTimelineDndBindings } from "./useTimelineDndBindings"; +import { useTimelineKeyboardShortcuts } from "./useTimelineKeyboardShortcuts"; +import { useTimelineNormalization } from "./useTimelineNormalization"; +import { useTimelineSelection } from "./useTimelineSelection"; interface UseTimelineEditorRuntimeParams { ref: ForwardedRef; @@ -39,6 +40,10 @@ interface UseTimelineEditorRuntimeParams { onSelectZoom: (id: string | null) => void; trimRegions: TrimRegion[]; onTrimSpanChange?: (id: string, span: Span) => void; + onTrimAdded?: (splitMs: number, trimDurationMs: number) => void; + onTrimDelete?: (id: string) => void; + selectedTrimId?: string | null; + onSelectTrim?: (id: string | null) => void; clipRegions: ClipRegion[]; onClipSplit?: (splitMs: number) => void; onClipSpanChange?: (id: string, span: Span) => void; @@ -83,6 +88,10 @@ export function useTimelineEditorRuntime({ onSelectZoom, trimRegions, onTrimSpanChange, + onTrimAdded, + onTrimDelete, + selectedTrimId, + onSelectTrim, clipRegions, onClipSplit, onClipSpanChange, @@ -118,12 +127,14 @@ export function useTimelineEditorRuntime({ deleteSelectedKeyframe, handleKeyframeMove, deleteSelectedZoom, + deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, clearSelectedBlocks, deleteAllBlocks, handleSelectZoom, + handleSelectTrim, handleSelectClip, handleSelectAnnotation, handleSelectAudio, @@ -132,18 +143,22 @@ export function useTimelineEditorRuntime({ totalMs, currentTimeMs, zoomRegions, + trimRegions, clipRegions, annotationRegions, audioRegions, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, onZoomDelete, + onTrimDelete, onClipDelete, onAnnotationDelete, onAudioDelete, onSelectZoom, + onSelectTrim, onSelectClip, onSelectAnnotation, onSelectAudio, @@ -162,33 +177,43 @@ export function useTimelineEditorRuntime({ onAudioSpanChange, }); - const { hasOverlap, timelineItems, allRegionSpans, getResolvedDropRowId, handleItemSpanChange } = - useTimelineDndBindings({ - zoomRegions, - trimRegions, - clipRegions, - annotationRegions, - speedRegions, - audioRegions, - onZoomSpanChange, - onTrimSpanChange, - onClipSpanChange, - onAnnotationSpanChange, - onSpeedSpanChange, - onAudioSpanChange, - }); + const { + hasOverlap, + timelineItems, + allRegionSpans, + getResolvedDropRowId, + handleItemSpanChange, + } = useTimelineDndBindings({ + zoomRegions, + trimRegions, + clipRegions, + annotationRegions, + speedRegions, + audioRegions, + onZoomSpanChange, + onTrimSpanChange, + onClipSpanChange, + onAnnotationSpanChange, + onSpeedSpanChange, + onAudioSpanChange, + }); - const { defaultRegionDurationMs, canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, handleSuggestZooms } = - useTimelineZoomActions({ - timeline: { videoDuration, totalMs, currentTimeMs }, - regions: { zoom: zoomRegions, clip: clipRegions }, - cursorTelemetry, - options: { disableSuggestedZooms }, - autoSuggestZoomsTrigger, - onAutoSuggestZoomsConsumed, - onZoomAdded, - onZoomSuggested, - }); + const { + defaultRegionDurationMs, + canPlaceZoomAtMs, + addZoomAtMs, + handleAddZoom, + handleSuggestZooms, + } = useTimelineZoomActions({ + timeline: { videoDuration, totalMs, currentTimeMs }, + regions: { zoom: zoomRegions, clip: clipRegions }, + cursorTelemetry, + options: { disableSuggestedZooms }, + autoSuggestZoomsTrigger, + onAutoSuggestZoomsConsumed, + onZoomAdded, + onZoomSuggested, + }); const handleSplitClip = useCallback(() => { if (!videoDuration || videoDuration === 0 || totalMs === 0 || !onClipSplit) { @@ -197,6 +222,12 @@ export function useTimelineEditorRuntime({ onClipSplit(currentTimeMs); }, [videoDuration, totalMs, currentTimeMs, onClipSplit]); + const { handleAddTrim } = useTimelineTrimActions({ + timeline: { videoDuration, totalMs, currentTimeMs }, + regions: { trim: trimRegions }, + onTrimAdded, + }); + const { handleAddAudio } = useTimelineAudioActions({ timeline: { videoDuration, totalMs, currentTimeMs }, regions: { audio: audioRegions }, @@ -230,6 +261,7 @@ export function useTimelineEditorRuntime({ annotationCount: annotationRegions.length, selectedKeyframeId, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, @@ -238,11 +270,13 @@ export function useTimelineEditorRuntime({ setSelectedKeyframeId, addKeyframe, handleAddZoom, + handleAddTrim, handleSplitClip, handleAddAnnotation: () => handleAddAnnotation(), deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, + deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, @@ -253,13 +287,22 @@ export function useTimelineEditorRuntime({ ref, () => ({ addZoom: handleAddZoom, + addTrim: handleAddTrim, suggestZooms: handleSuggestZooms, splitClip: handleSplitClip, addAnnotation: handleAddAnnotation, addAudio: handleAddAudio, keyframes, }), - [handleAddAnnotation, handleAddAudio, handleAddZoom, handleSuggestZooms, handleSplitClip, keyframes], + [ + handleAddAnnotation, + handleAddAudio, + handleAddTrim, + handleAddZoom, + handleSuggestZooms, + handleSplitClip, + keyframes, + ], ); return { @@ -271,6 +314,7 @@ export function useTimelineEditorRuntime({ handleKeyframeMove, clearSelectedBlocks, handleSelectZoom, + handleSelectTrim, handleSelectClip, handleSelectAnnotation, handleSelectAudio, @@ -282,6 +326,7 @@ export function useTimelineEditorRuntime({ canPlaceZoomAtMs, addZoomAtMs, handleAddZoom, + handleAddTrim, handleSuggestZooms, handleSplitClip, handleAddAudio, diff --git a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts index 55d2c0b6a..21fcdf58d 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineKeyboardShortcuts.ts @@ -1,4 +1,4 @@ -import { useEffect, type RefObject } from "react"; +import { type RefObject, useEffect } from "react"; import { matchesShortcut } from "@/lib/shortcuts"; import type { TimelineShortcutBindings } from "../core/timelineTypes"; import { resolveDeleteSelectionTarget } from "./utils/timelineSelectionUtils"; @@ -11,6 +11,7 @@ interface UseTimelineKeyboardShortcutsParams { annotationCount: number; selectedKeyframeId: string | null; selectedZoomId: string | null; + selectedTrimId?: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; @@ -19,11 +20,13 @@ interface UseTimelineKeyboardShortcutsParams { setSelectedKeyframeId: (id: string | null) => void; addKeyframe: () => void; handleAddZoom: () => void; + handleAddTrim: () => void; handleSplitClip: () => void; handleAddAnnotation: () => void; deleteAllBlocks: () => void; deleteSelectedKeyframe: () => void; deleteSelectedZoom: () => void; + deleteSelectedTrim: () => void; deleteSelectedClip: () => void; deleteSelectedAnnotation: () => void; deleteSelectedAudio: () => void; @@ -38,6 +41,7 @@ export function useTimelineKeyboardShortcuts({ annotationCount, selectedKeyframeId, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, @@ -46,11 +50,13 @@ export function useTimelineKeyboardShortcuts({ setSelectedKeyframeId, addKeyframe, handleAddZoom, + handleAddTrim, handleSplitClip, handleAddAnnotation, deleteAllBlocks, deleteSelectedKeyframe, deleteSelectedZoom, + deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, @@ -84,6 +90,7 @@ export function useTimelineKeyboardShortcuts({ if (matchesShortcut(e, keyShortcuts.addKeyframe, isMac)) addKeyframe(); if (matchesShortcut(e, keyShortcuts.addZoom, isMac)) handleAddZoom(); + if (matchesShortcut(e, keyShortcuts.addTrim, isMac)) handleAddTrim(); if (matchesShortcut(e, keyShortcuts.splitClip, isMac)) handleSplitClip(); if (matchesShortcut(e, keyShortcuts.addAnnotation, isMac)) { handleAddAnnotation(); @@ -104,6 +111,7 @@ export function useTimelineKeyboardShortcuts({ selectAllBlocksActive, selectedKeyframeId, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, @@ -117,6 +125,8 @@ export function useTimelineKeyboardShortcuts({ deleteSelectedKeyframe(); } else if (target === "zoom") { deleteSelectedZoom(); + } else if (target === "trim") { + deleteSelectedTrim(); } else if (target === "clip") { deleteSelectedClip(); } else if (target === "annotation") { @@ -138,8 +148,10 @@ export function useTimelineKeyboardShortcuts({ deleteSelectedAudio, deleteSelectedClip, deleteSelectedKeyframe, + deleteSelectedTrim, deleteSelectedZoom, handleAddAnnotation, + handleAddTrim, handleAddZoom, handleSplitClip, hasAnyTimelineBlocks, @@ -151,6 +163,7 @@ export function useTimelineKeyboardShortcuts({ selectedAudioId, selectedClipId, selectedKeyframeId, + selectedTrimId, selectedZoomId, setSelectAllBlocksActive, setSelectedKeyframeId, diff --git a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts index 1b4fd4989..3e618ad1c 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineNormalization.ts @@ -1,6 +1,6 @@ import { useEffect } from "react"; -import { normalizeRegionSpan } from "../core/spans"; import type { AudioRegion, SpeedRegion, TrimRegion, ZoomRegion } from "../../types"; +import { normalizeRegionSpan } from "../core/spans"; interface UseTimelineNormalizationParams { totalMs: number; diff --git a/src/components/video-editor/timeline/hooks/useTimelineRange.ts b/src/components/video-editor/timeline/hooks/useTimelineRange.ts index e3d5ff598..2b72da2f5 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineRange.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineRange.ts @@ -1,5 +1,5 @@ import type { Range } from "dnd-timeline"; -import { useCallback, useEffect, useMemo, useState, type RefObject, type WheelEvent } from "react"; +import { type RefObject, useCallback, useEffect, useMemo, useState, type WheelEvent } from "react"; import { createInitialRange, normalizeWheelDeltaToPixels } from "../core/time"; interface UseTimelineRangeParams { @@ -64,7 +64,10 @@ export function useTimelineRange({ totalMs, timelineContainerRef }: UseTimelineR } event.preventDefault(); - const horizontalDeltaPx = normalizeWheelDeltaToPixels(rawHorizontalDelta, event.deltaMode); + const horizontalDeltaPx = normalizeWheelDeltaToPixels( + rawHorizontalDelta, + event.deltaMode, + ); const deltaMs = (horizontalDeltaPx / containerWidth) * visibleRangeMs; panTimelineRange(deltaMs); }, diff --git a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts index 2d78162e6..ef7307737 100644 --- a/src/components/video-editor/timeline/hooks/useTimelineSelection.ts +++ b/src/components/video-editor/timeline/hooks/useTimelineSelection.ts @@ -6,18 +6,22 @@ interface UseTimelineSelectionParams { totalMs: number; currentTimeMs: number; zoomRegions: TimelineRegion[]; + trimRegions: TimelineRegion[]; clipRegions: TimelineRegion[]; annotationRegions: (TimelineRegion & { zIndex: number })[]; audioRegions: TimelineRegion[]; selectedZoomId: string | null; + selectedTrimId?: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; onZoomDelete: (id: string) => void; + onTrimDelete?: (id: string) => void; onClipDelete?: (id: string) => void; onAnnotationDelete?: (id: string) => void; onAudioDelete?: (id: string) => void; onSelectZoom: (id: string | null) => void; + onSelectTrim?: (id: string | null) => void; onSelectClip?: (id: string | null) => void; onSelectAnnotation?: (id: string | null) => void; onSelectAudio?: (id: string | null) => void; @@ -27,18 +31,22 @@ export function useTimelineSelection({ totalMs, currentTimeMs, zoomRegions, + trimRegions, clipRegions, annotationRegions, audioRegions, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, onZoomDelete, + onTrimDelete, onClipDelete, onAnnotationDelete, onAudioDelete, onSelectZoom, + onSelectTrim, onSelectClip, onSelectAnnotation, onSelectAudio, @@ -77,6 +85,12 @@ export function useTimelineSelection({ onSelectZoom(null); }, [selectedZoomId, onZoomDelete, onSelectZoom]); + const deleteSelectedTrim = useCallback(() => { + if (!selectedTrimId || !onTrimDelete || !onSelectTrim) return; + onTrimDelete(selectedTrimId); + onSelectTrim(null); + }, [selectedTrimId, onTrimDelete, onSelectTrim]); + const deleteSelectedClip = useCallback(() => { if (!selectedClipId || !onClipDelete || !onSelectClip) return; onClipDelete(selectedClipId); @@ -97,23 +111,32 @@ export function useTimelineSelection({ const clearSelectedBlocks = useCallback(() => { onSelectZoom(null); + onSelectTrim?.(null); onSelectClip?.(null); onSelectAnnotation?.(null); onSelectAudio?.(null); setSelectAllBlocksActive(false); - }, [onSelectZoom, onSelectClip, onSelectAnnotation, onSelectAudio]); + }, [onSelectZoom, onSelectTrim, onSelectClip, onSelectAnnotation, onSelectAudio]); const hasAnyTimelineBlocks = useMemo( () => zoomRegions.length > 0 || + trimRegions.length > 0 || clipRegions.length > 0 || annotationRegions.length > 0 || audioRegions.length > 0, - [zoomRegions.length, clipRegions.length, annotationRegions.length, audioRegions.length], + [ + zoomRegions.length, + trimRegions.length, + clipRegions.length, + annotationRegions.length, + audioRegions.length, + ], ); const deleteAllBlocks = useCallback(() => { zoomRegions.map((r) => r.id).forEach((id) => onZoomDelete(id)); + trimRegions.map((r) => r.id).forEach((id) => onTrimDelete?.(id)); clipRegions.map((r) => r.id).forEach((id) => onClipDelete?.(id)); annotationRegions.map((r) => r.id).forEach((id) => onAnnotationDelete?.(id)); audioRegions.map((r) => r.id).forEach((id) => onAudioDelete?.(id)); @@ -121,10 +144,12 @@ export function useTimelineSelection({ setSelectedKeyframeId(null); }, [ zoomRegions, + trimRegions, clipRegions, annotationRegions, audioRegions, onZoomDelete, + onTrimDelete, onClipDelete, onAnnotationDelete, onAudioDelete, @@ -139,6 +164,14 @@ export function useTimelineSelection({ [onSelectZoom], ); + const handleSelectTrim = useCallback( + (id: string | null) => { + setSelectAllBlocksActive(false); + onSelectTrim?.(id); + }, + [onSelectTrim], + ); + const handleSelectClip = useCallback( (id: string | null) => { setSelectAllBlocksActive(false); @@ -198,12 +231,14 @@ export function useTimelineSelection({ deleteSelectedKeyframe, handleKeyframeMove, deleteSelectedZoom, + deleteSelectedTrim, deleteSelectedClip, deleteSelectedAnnotation, deleteSelectedAudio, clearSelectedBlocks, deleteAllBlocks, handleSelectZoom, + handleSelectTrim, handleSelectClip, handleSelectAnnotation, handleSelectAudio, diff --git a/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts index 4be4e3827..863344494 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineAudioPlacement.ts @@ -30,7 +30,10 @@ export function resolveAudioPlacement({ const normalizedPreferredTrackIndex = Number.isFinite(preferredTrackIndex) ? Math.max(0, Math.floor(preferredTrackIndex ?? 0)) : null; - const maxTrackIndex = audioRegions.reduce((max, region) => Math.max(max, region.trackIndex ?? 0), -1); + const maxTrackIndex = audioRegions.reduce( + (max, region) => Math.max(max, region.trackIndex ?? 0), + -1, + ); const candidateTrackIndexes = normalizedPreferredTrackIndex === null ? Array.from({ length: maxTrackIndex + 2 }, (_, index) => index) diff --git a/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts b/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts index 37b858200..7843b1d5b 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineNotifications.ts @@ -9,5 +9,6 @@ export interface TimelineNotifications { export const timelineNotifications: TimelineNotifications = { error: (title, description) => toast.error(title, description ? { description } : undefined), info: (title, description) => toast.info(title, description ? { description } : undefined), - success: (title, description) => toast.success(title, description ? { description } : undefined), + success: (title, description) => + toast.success(title, description ? { description } : undefined), }; diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts index 3a93afde0..2584b91e6 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.test.ts @@ -8,6 +8,7 @@ describe("timelineSelectionUtils", () => { selectAllBlocksActive: true, selectedKeyframeId: "kf-1", selectedZoomId: "z-1", + selectedTrimId: "t-1", selectedClipId: "c-1", selectedAnnotationId: "a-1", selectedAudioId: "au-1", @@ -28,6 +29,7 @@ describe("timelineSelectionUtils", () => { selectAllBlocksActive: false, selectedKeyframeId: null, selectedZoomId: "z-1", + selectedTrimId: "t-1", selectedClipId: "c-1", }), ).toBe("zoom"); @@ -36,6 +38,16 @@ describe("timelineSelectionUtils", () => { selectAllBlocksActive: false, selectedKeyframeId: null, selectedZoomId: null, + selectedTrimId: "t-1", + selectedClipId: "c-1", + }), + ).toBe("trim"); + expect( + resolveDeleteSelectionTarget({ + selectAllBlocksActive: false, + selectedKeyframeId: null, + selectedZoomId: null, + selectedTrimId: null, selectedClipId: "c-1", selectedAnnotationId: "a-1", }), diff --git a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts index ac2b12996..56d98f3ff 100644 --- a/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts +++ b/src/components/video-editor/timeline/hooks/utils/timelineSelectionUtils.ts @@ -2,6 +2,7 @@ export type DeleteSelectionTarget = | "all" | "keyframe" | "zoom" + | "trim" | "clip" | "annotation" | "audio" @@ -11,6 +12,7 @@ interface ResolveDeleteSelectionTargetParams { selectAllBlocksActive: boolean; selectedKeyframeId: string | null; selectedZoomId: string | null; + selectedTrimId?: string | null; selectedClipId?: string | null; selectedAnnotationId?: string | null; selectedAudioId?: string | null; @@ -20,6 +22,7 @@ export function resolveDeleteSelectionTarget({ selectAllBlocksActive, selectedKeyframeId, selectedZoomId, + selectedTrimId, selectedClipId, selectedAnnotationId, selectedAudioId, @@ -27,6 +30,7 @@ export function resolveDeleteSelectionTarget({ if (selectAllBlocksActive) return "all"; if (selectedKeyframeId) return "keyframe"; if (selectedZoomId) return "zoom"; + if (selectedTrimId) return "trim"; if (selectedClipId) return "clip"; if (selectedAnnotationId) return "annotation"; if (selectedAudioId) return "audio"; diff --git a/src/components/video-editor/timeline/model/timelineModel.test.ts b/src/components/video-editor/timeline/model/timelineModel.test.ts index f530f2d66..0efcc8a0f 100644 --- a/src/components/video-editor/timeline/model/timelineModel.test.ts +++ b/src/components/video-editor/timeline/model/timelineModel.test.ts @@ -33,12 +33,25 @@ describe("timeline model", () => { zoomRegions: [ { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, ], + trimRegions: [], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], annotationRegions: [ - { ...BASE_ANNOTATION, type: "text" as const, content: "Hello timeline", trackIndex: 1 }, + { + ...BASE_ANNOTATION, + type: "text" as const, + content: "Hello timeline", + trackIndex: 1, + }, ], audioRegions: [ - { id: "au1", startMs: 500, endMs: 2000, audioPath: "/tmp/foo.mp3", volume: 1, trackIndex: 0 }, + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "/tmp/foo.mp3", + volume: 1, + trackIndex: 0, + }, ], }); @@ -65,8 +78,18 @@ describe("timeline model", () => { "Annotation", ); - expect(getAudioLabel({ id: "1", startMs: 0, endMs: 1, audioPath: "C:\\x\\y\\z.wav", volume: 1 })).toBe("z"); - expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe("Audio"); + expect( + getAudioLabel({ + id: "1", + startMs: 0, + endMs: 1, + audioPath: "C:\\x\\y\\z.wav", + volume: 1, + }), + ).toBe("z"); + expect(getAudioLabel({ id: "2", startMs: 0, endMs: 1, audioPath: "", volume: 1 })).toBe( + "Audio", + ); }); it("builds row spans for dnd constraints", () => { @@ -74,9 +97,17 @@ describe("timeline model", () => { zoomRegions: [ { id: "z1", startMs: 0, endMs: 1000, depth: 2, focus: { cx: 0.5, cy: 0.5 } }, ], + trimRegions: [], clipRegions: [{ id: "c1", startMs: 0, endMs: 4000, speed: 1 }], audioRegions: [ - { id: "au1", startMs: 500, endMs: 2000, audioPath: "x.wav", volume: 1, trackIndex: 2 }, + { + id: "au1", + startMs: 500, + endMs: 2000, + audioPath: "x.wav", + volume: 1, + trackIndex: 2, + }, ], }); expect(spans.map((s) => s.rowId)).toEqual(["row-zoom", "row-clip", "row-audio-2"]); @@ -84,9 +115,27 @@ describe("timeline model", () => { it("keeps items in their domain rows during dnd", () => { const items = [ - { id: "a1", rowId: "row-annotation-1", span: { start: 0, end: 1 }, label: "A", variant: "annotation" as const }, - { id: "au1", rowId: "row-audio-2", span: { start: 0, end: 1 }, label: "X", variant: "audio" as const }, - { id: "z1", rowId: "row-zoom", span: { start: 0, end: 1 }, label: "Z", variant: "zoom" as const }, + { + id: "a1", + rowId: "row-annotation-1", + span: { start: 0, end: 1 }, + label: "A", + variant: "annotation" as const, + }, + { + id: "au1", + rowId: "row-audio-2", + span: { start: 0, end: 1 }, + label: "X", + variant: "audio" as const, + }, + { + id: "z1", + rowId: "row-zoom", + span: { start: 0, end: 1 }, + label: "Z", + variant: "zoom" as const, + }, ]; expect(resolveDropRowId("a1", "row-audio-0", items)).toBe("row-annotation-1"); expect(resolveDropRowId("a1", "row-annotation-3", items)).toBe("row-annotation-3"); @@ -95,4 +144,41 @@ describe("timeline model", () => { expect(resolveDropRowId("z1", "row-audio-1", items)).toBe("row-zoom"); expect(resolveDropRowId("unknown", "row-audio-1", items)).toBe("row-audio-1"); }); + + it("includes trim items with correct row, variant, and labels", () => { + const items = buildTimelineItems({ + zoomRegions: [], + trimRegions: [ + { id: "t1", startMs: 1000, endMs: 3000 }, + { id: "t2", startMs: 5000, endMs: 7000 }, + ], + clipRegions: [], + annotationRegions: [], + audioRegions: [], + }); + + const trimItems = items.filter((i) => i.variant === "trim"); + expect(trimItems).toHaveLength(2); + expect(trimItems[0].rowId).toBe("row-trim"); + expect(trimItems[0].label).toBe("Trim 1"); + expect(trimItems[0].span).toEqual({ start: 1000, end: 3000 }); + expect(trimItems[1].label).toBe("Trim 2"); + }); + + it("includes trim spans in region spans for dnd constraints", () => { + const spans = buildAllRegionSpans({ + zoomRegions: [], + trimRegions: [{ id: "t1", startMs: 2000, endMs: 4000 }], + clipRegions: [], + audioRegions: [], + }); + + expect(spans).toHaveLength(1); + expect(spans[0]).toEqual({ + id: "t1", + start: 2000, + end: 4000, + rowId: "row-trim", + }); + }); }); diff --git a/src/components/video-editor/timeline/model/timelineModel.ts b/src/components/video-editor/timeline/model/timelineModel.ts index 0981b85c1..3ff01531b 100644 --- a/src/components/video-editor/timeline/model/timelineModel.ts +++ b/src/components/video-editor/timeline/model/timelineModel.ts @@ -2,10 +2,10 @@ import type { AnnotationRegion, AudioRegion, ClipRegion, + TrimRegion, ZoomRegion, } from "../../types"; -import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes"; -import { CLIP_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; +import { CLIP_ROW_ID, TRIM_ROW_ID, ZOOM_ROW_ID } from "../core/constants"; import { getAnnotationTrackIndex, getAnnotationTrackRowId, @@ -14,6 +14,7 @@ import { isAnnotationTrackRowId, isAudioTrackRowId, } from "../core/rows"; +import type { TimelineRegionSpan, TimelineRenderItem } from "../core/timelineTypes"; export function getAnnotationLabel(region: AnnotationRegion): string { if (region.type === "text") { @@ -27,16 +28,22 @@ export function getAnnotationLabel(region: AnnotationRegion): string { } export function getAudioLabel(region: AudioRegion): string { - return region.audioPath.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, "") || "Audio"; + return ( + region.audioPath + .split(/[\\/]/) + .pop() + ?.replace(/\.[^.]+$/, "") || "Audio" + ); } export function buildTimelineItems(params: { zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; clipRegions: ClipRegion[]; annotationRegions: AnnotationRegion[]; audioRegions: AudioRegion[]; }): TimelineRenderItem[] { - const { zoomRegions, clipRegions, annotationRegions, audioRegions } = params; + const { zoomRegions, trimRegions, clipRegions, annotationRegions, audioRegions } = params; const zooms: TimelineRenderItem[] = zoomRegions.map((region, index) => ({ id: region.id, rowId: ZOOM_ROW_ID, @@ -47,6 +54,14 @@ export function buildTimelineItems(params: { variant: "zoom", })); + const trims: TimelineRenderItem[] = trimRegions.map((region, index) => ({ + id: region.id, + rowId: TRIM_ROW_ID, + span: { start: region.startMs, end: region.endMs }, + label: `Trim ${index + 1}`, + variant: "trim", + })); + const clips: TimelineRenderItem[] = clipRegions.map((region, index) => ({ id: region.id, rowId: CLIP_ROW_ID, @@ -71,21 +86,28 @@ export function buildTimelineItems(params: { variant: "audio", })); - return [...zooms, ...clips, ...annotations, ...audios]; + return [...zooms, ...trims, ...clips, ...annotations, ...audios]; } export function buildAllRegionSpans(params: { zoomRegions: ZoomRegion[]; + trimRegions: TrimRegion[]; clipRegions: ClipRegion[]; audioRegions: AudioRegion[]; }): TimelineRegionSpan[] { - const { zoomRegions, clipRegions, audioRegions } = params; + const { zoomRegions, trimRegions, clipRegions, audioRegions } = params; const zooms = zoomRegions.map((r) => ({ id: r.id, start: r.startMs, end: r.endMs, rowId: ZOOM_ROW_ID, })); + const trims = trimRegions.map((r) => ({ + id: r.id, + start: r.startMs, + end: r.endMs, + rowId: TRIM_ROW_ID, + })); const clips = clipRegions.map((r) => ({ id: r.id, start: r.startMs, @@ -98,7 +120,7 @@ export function buildAllRegionSpans(params: { end: r.endMs, rowId: getAudioTrackRowId(r.trackIndex ?? 0), })); - return [...zooms, ...clips, ...audios]; + return [...zooms, ...trims, ...clips, ...audios]; } export function resolveDropRowId( diff --git a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts index 77080bfc5..0630b0ae5 100644 --- a/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts +++ b/src/components/video-editor/timeline/zoomSuggestionUtils.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; +import type { CursorTelemetryPoint } from "../types"; import { + buildInteractionZoomSuggestions, CLICK_CLUSTER_MERGE_GAP_MS, CLICK_CLUSTER_PAD_MS, - buildInteractionZoomSuggestions, } from "./zoomSuggestionUtils"; -import type { CursorTelemetryPoint } from "../types"; function makeClick( timeMs: number, @@ -20,15 +20,8 @@ function makeMove(timeMs: number, cx = 0.5, cy = 0.5): CursorTelemetryPoint { } /** Wraps click samples with surrounding move events to mimic real mixed telemetry. */ -function withMoves( - clicks: CursorTelemetryPoint[], - totalMs: number, -): CursorTelemetryPoint[] { - return [ - makeMove(0), - ...clicks, - makeMove(totalMs), - ]; +function withMoves(clicks: CursorTelemetryPoint[], totalMs: number): CursorTelemetryPoint[] { + return [makeMove(0), ...clicks, makeMove(totalMs)]; } const TOTAL_MS = 30_000; @@ -62,23 +55,23 @@ describe("buildInteractionZoomSuggestions (click-cluster logic)", () => { expect(result.suggestions).toHaveLength(1); }); - it.each(["right-click", "middle-click"] as const)( - "accepts %s telemetry like a standard click", - (interactionType) => { - const result = buildInteractionZoomSuggestions({ - cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS), - totalMs: TOTAL_MS, - defaultDurationMs: 3_000, - }); - - expect(result.status).toBe("ok"); - expect(result.suggestions).toHaveLength(1); - - const [suggestion] = result.suggestions; - expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); - expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); - }, - ); + it.each([ + "right-click", + "middle-click", + ] as const)("accepts %s telemetry like a standard click", (interactionType) => { + const result = buildInteractionZoomSuggestions({ + cursorTelemetry: withMoves([makeClick(5_000, 0.5, 0.5, interactionType)], TOTAL_MS), + totalMs: TOTAL_MS, + defaultDurationMs: 3_000, + }); + + expect(result.status).toBe("ok"); + expect(result.suggestions).toHaveLength(1); + + const [suggestion] = result.suggestions; + expect(suggestion.start).toBe(5_000 - CLICK_CLUSTER_PAD_MS); + expect(suggestion.end).toBe(5_000 + CLICK_CLUSTER_PAD_MS); + }); it("merges two clicks within 2500ms into one zoom track", () => { const telemetry = withMoves( diff --git a/src/components/video-editor/types.test.ts b/src/components/video-editor/types.test.ts index 6da7a8da7..34b3502bf 100644 --- a/src/components/video-editor/types.test.ts +++ b/src/components/video-editor/types.test.ts @@ -155,7 +155,12 @@ describe("clip timeline mapping", () => { ); expect(clipsFromTrims.map((clip) => clip.id)).toEqual(["clip-1", "clip-2", "clip-3"]); - expect(deriveNextId("clip", clipsFromTrims.map((clip) => clip.id))).toBe(4); + expect( + deriveNextId( + "clip", + clipsFromTrims.map((clip) => clip.id), + ), + ).toBe(4); }); }); @@ -171,10 +176,7 @@ describe("getTimelineDurationMs", () => { it("keeps the source duration when speed edits make clips shorter", () => { expect( - getTimelineDurationMs( - [{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], - 10_000, - ), + getTimelineDurationMs([{ id: "clip-1", startMs: 0, endMs: 5_000, speed: 2 }], 10_000), ).toBe(10_000); }); }); diff --git a/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts b/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts index 8493623a3..5b7d53ea5 100644 --- a/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts +++ b/src/components/video-editor/videoPlayback/cursorFollowCamera.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - computeCursorFollowFocus, - createCursorFollowCameraState, -} from "./cursorFollowCamera"; +import { computeCursorFollowFocus, createCursorFollowCameraState } from "./cursorFollowCamera"; describe("computeCursorFollowFocus", () => { it("holds the camera while the cursor stays inside the safe zone", () => { @@ -107,4 +104,4 @@ describe("computeCursorFollowFocus", () => { expect(clampedFocus).toEqual({ cx: 0.75, cy: 0.75 }); }); -}); \ No newline at end of file +}); diff --git a/src/components/video-editor/videoPlayback/cursorFollowCamera.ts b/src/components/video-editor/videoPlayback/cursorFollowCamera.ts index 75106a818..d98e85e5c 100644 --- a/src/components/video-editor/videoPlayback/cursorFollowCamera.ts +++ b/src/components/video-editor/videoPlayback/cursorFollowCamera.ts @@ -152,9 +152,7 @@ export function computeCursorFollowFocus( const cursorPos = interpolateCursorPosition(cursorSamples, timeMs); if (!cursorPos) { - return state.initialized - ? { cx: state.focusX, cy: state.focusY } - : clampedRegionFocus; + return state.initialized ? { cx: state.focusX, cy: state.focusY } : clampedRegionFocus; } // Track when zoom reaches full strength diff --git a/src/components/video-editor/videoPlayback/cursorRenderer.ts b/src/components/video-editor/videoPlayback/cursorRenderer.ts index 644cf8992..40d598bf7 100644 --- a/src/components/video-editor/videoPlayback/cursorRenderer.ts +++ b/src/components/video-editor/videoPlayback/cursorRenderer.ts @@ -745,10 +745,7 @@ function getCursorViewportScale(viewport: CursorViewportRect) { return Math.max(MIN_CURSOR_VIEWPORT_SCALE, viewport.width / REFERENCE_WIDTH); } -function getCursorSwaySpringConfig( - smoothingFactor: number, - springTuning: CursorSpringTuning, -) { +function getCursorSwaySpringConfig(smoothingFactor: number, springTuning: CursorSpringTuning) { const baseConfig = getCursorSpringConfig( Math.min( 2, @@ -812,7 +809,9 @@ export class SmoothedCursorState { private xSpring = createSpringState(0.5); private ySpring = createSpringState(0.5); - constructor(config: Pick) { + constructor( + config: Pick, + ) { this.smoothingFactor = config.smoothingFactor; this.springTuning = config.springTuning; this.trailLength = config.trailLength; diff --git a/src/components/video-editor/videoPlayback/layoutUtils.ts b/src/components/video-editor/videoPlayback/layoutUtils.ts index 1b2aef7b0..59c3f6090 100644 --- a/src/components/video-editor/videoPlayback/layoutUtils.ts +++ b/src/components/video-editor/videoPlayback/layoutUtils.ts @@ -8,12 +8,7 @@ export function isZeroPadding(padding: Padding | number): boolean { if (typeof padding === "number") { return padding === 0; } - return ( - padding.top === 0 && - padding.bottom === 0 && - padding.left === 0 && - padding.right === 0 - ); + return padding.top === 0 && padding.bottom === 0 && padding.left === 0 && padding.right === 0; } export interface PaddedLayoutResult { @@ -93,12 +88,8 @@ export function computePaddedLayout(params: { const frameCenterX = availableCenterX - fullFrameDisplayW / 2; const frameCenterY = availableCenterY - fullFrameDisplayH / 2; - const centerOffsetX = insets - ? frameCenterX + insets.left * fullFrameDisplayW - : frameCenterX; - const centerOffsetY = insets - ? frameCenterY + insets.top * fullFrameDisplayH - : frameCenterY; + const centerOffsetX = insets ? frameCenterX + insets.left * fullFrameDisplayW : frameCenterX; + const centerOffsetY = insets ? frameCenterY + insets.top * fullFrameDisplayH : frameCenterY; const spriteX = centerOffsetX - crop.x * fullVideoDisplayWidth; const spriteY = centerOffsetY - crop.y * fullVideoDisplayHeight; diff --git a/src/components/video-editor/videoPlayback/motionSmoothing.ts b/src/components/video-editor/videoPlayback/motionSmoothing.ts index 6ffed00e3..c4ec3852b 100644 --- a/src/components/video-editor/videoPlayback/motionSmoothing.ts +++ b/src/components/video-editor/videoPlayback/motionSmoothing.ts @@ -35,10 +35,7 @@ function clampSpringMultiplier(value: number | undefined) { return Math.min(3, Math.max(0.25, numericValue)); } -function applyCursorSpringTuning( - config: SpringConfig, - tuning?: CursorSpringTuning, -): SpringConfig { +function applyCursorSpringTuning(config: SpringConfig, tuning?: CursorSpringTuning): SpringConfig { return { ...config, stiffness: config.stiffness * clampSpringMultiplier(tuning?.stiffnessMultiplier), @@ -226,13 +223,16 @@ export function getCursorSpringConfig( const clamped = Math.min(CURSOR_SMOOTHING_MAX, Math.max(CURSOR_SMOOTHING_MIN, smoothingFactor)); if (clamped <= 0) { - return applyCursorSpringTuning({ - stiffness: 1000, - damping: 100, - mass: 1, - restDelta: 0.0001, - restSpeed: 0.001, - }, tuning); + return applyCursorSpringTuning( + { + stiffness: 1000, + damping: 100, + mass: 1, + restDelta: 0.0001, + restSpeed: 0.001, + }, + tuning, + ); } if (clamped <= CURSOR_SMOOTHING_LEGACY_MAX) { @@ -245,13 +245,16 @@ export function getCursorSpringConfig( ), ); - return applyCursorSpringTuning({ - stiffness: (760 - legacyNormalized * 420) * DEFAULT_CURSOR_STIFFNESS_BOOST, - damping: 34 + legacyNormalized * 24, - mass: 0.85 + legacyNormalized * 0.55, - restDelta: 0.0002, - restSpeed: 0.01, - }, tuning); + return applyCursorSpringTuning( + { + stiffness: (760 - legacyNormalized * 420) * DEFAULT_CURSOR_STIFFNESS_BOOST, + damping: 34 + legacyNormalized * 24, + mass: 0.85 + legacyNormalized * 0.55, + restDelta: 0.0002, + restSpeed: 0.01, + }, + tuning, + ); } const extendedNormalized = Math.min( @@ -263,13 +266,16 @@ export function getCursorSpringConfig( ), ); - return applyCursorSpringTuning({ - stiffness: (340 - extendedNormalized * 180) * DEFAULT_CURSOR_STIFFNESS_BOOST, - damping: 58 + extendedNormalized * 22, - mass: 1.35 + extendedNormalized * 0.45, - restDelta: 0.0002, - restSpeed: 0.01, - }, tuning); + return applyCursorSpringTuning( + { + stiffness: (340 - extendedNormalized * 180) * DEFAULT_CURSOR_STIFFNESS_BOOST, + damping: 58 + extendedNormalized * 22, + mass: 1.35 + extendedNormalized * 0.45, + restDelta: 0.0002, + restSpeed: 0.01, + }, + tuning, + ); } export function getZoomSpringConfig( @@ -279,13 +285,16 @@ export function getZoomSpringConfig( const clamped = Math.max(0, Math.min(1, smoothnessFactor)); if (clamped <= 0) { - return applyCursorSpringTuning({ - stiffness: 1000, - damping: 100, - mass: 1, - restDelta: 0.0001, - restSpeed: 0.001, - }, tuning); + return applyCursorSpringTuning( + { + stiffness: 1000, + damping: 100, + mass: 1, + restDelta: 0.0001, + restSpeed: 0.001, + }, + tuning, + ); } // Map 0-1 slider to the internal 0-2 spring range so that @@ -297,11 +306,14 @@ export function getZoomSpringConfig( // The overshoot clamp in stepSpringValue prevents wobble even at // this low damping, so animations stay fast and responsive. // Higher scaled → lower stiffness + higher mass → slower, floatier settle. - return applyCursorSpringTuning({ - stiffness: 100 / scaled, - damping: 21, - mass: 1.0 * scaled, - restDelta: 0.0005, - restSpeed: 0.015, - }, tuning); + return applyCursorSpringTuning( + { + stiffness: 100 / scaled, + damping: 21, + mass: 1.0 * scaled, + restDelta: 0.0005, + restSpeed: 0.015, + }, + tuning, + ); } diff --git a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts index 38faffba3..0de329a02 100644 --- a/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts +++ b/src/components/video-editor/videoPlayback/uploadedCursorAssets.ts @@ -28,7 +28,8 @@ const TAHOE_POINTER_CONTENT_HEIGHT = 851; // Measured from the raw pointer assets using the non-shadow pixel bounds. const MACOS_CURSOR_STYLE_SIZE_MULTIPLIER = - (TAHOE_POINTER_CONTENT_HEIGHT / TAHOE_POINTER_ASSET_HEIGHT) / + TAHOE_POINTER_CONTENT_HEIGHT / + TAHOE_POINTER_ASSET_HEIGHT / (MACOS_POINTER_CONTENT_HEIGHT / MACOS_POINTER_ASSET_HEIGHT); export type UploadedCursorAsset = { diff --git a/src/components/video-editor/videoPlayback/webcamSync.test.ts b/src/components/video-editor/videoPlayback/webcamSync.test.ts index d8d1f5ac4..ca283a79e 100644 --- a/src/components/video-editor/videoPlayback/webcamSync.test.ts +++ b/src/components/video-editor/videoPlayback/webcamSync.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getWebcamMediaTargetTimeSeconds, - getWebcamPreviewTargetTimeSeconds, -} from "./webcamSync"; +import { getWebcamMediaTargetTimeSeconds, getWebcamPreviewTargetTimeSeconds } from "./webcamSync"; describe("getWebcamPreviewTargetTimeSeconds", () => { it("subtracts positive webcam offsets when the webcam started after the main capture", () => { diff --git a/src/components/video-editor/videoPlayback/zoomTransform.ts b/src/components/video-editor/videoPlayback/zoomTransform.ts index 65fe8ce7f..6fd1eb21b 100644 --- a/src/components/video-editor/videoPlayback/zoomTransform.ts +++ b/src/components/video-editor/videoPlayback/zoomTransform.ts @@ -340,18 +340,9 @@ function analyzeCameraStep({ motionBlurTuning: ZoomMotionBlurTuning; deltaSeconds: number; }): CameraStepAnalysis { - const mode = classifyMotionMode( - previousQuad, - currentQuad, - motionBlurTuning, - deltaSeconds, - ); + const mode = classifyMotionMode(previousQuad, currentQuad, motionBlurTuning, deltaSeconds); const moveDelta = computeMoveDelta(previousQuad, currentQuad); - const blurChannels = resolveBlurChannels( - motionBlurAmount, - motionBlurTuning, - deltaSeconds, - ); + const blurChannels = resolveBlurChannels(motionBlurAmount, motionBlurTuning, deltaSeconds); const moveBlurVelocity = { x: moveDelta.x * blurChannels.motion, y: moveDelta.y * blurChannels.motion, diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx index 4c3566ad9..3fe94ac15 100644 --- a/src/contexts/ThemeContext.tsx +++ b/src/contexts/ThemeContext.tsx @@ -30,9 +30,7 @@ function getStoredPreference(): ThemePreference { function resolveTheme(pref: ThemePreference): ResolvedTheme { if (pref === "system") { - return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches - ? "dark" - : "light"; + return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } return pref; } @@ -54,9 +52,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { return stored; }); - const [resolved, setResolved] = useState(() => - resolveTheme(preference), - ); + const [resolved, setResolved] = useState(() => resolveTheme(preference)); const setPreference = useCallback((pref: ThemePreference) => { setPreferenceState(pref); @@ -93,9 +89,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { }, [resolved]); return ( - + {children} ); diff --git a/src/hooks/recordingMimeType.test.ts b/src/hooks/recordingMimeType.test.ts index 9f4919f29..ca3e847e3 100644 --- a/src/hooks/recordingMimeType.test.ts +++ b/src/hooks/recordingMimeType.test.ts @@ -29,10 +29,7 @@ describe("selectRecordingMimeType", () => { it("skips recorder-only codecs when playback support is missing", () => { const mimeType = selectRecordingMimeType({ isTypeSupported: (type) => - [ - "video/webm;codecs=vp9", - "video/webm;codecs=vp8", - ].includes(type), + ["video/webm;codecs=vp9", "video/webm;codecs=vp8"].includes(type), canPlayType: (type) => (type === "video/webm;codecs=vp8" ? "probably" : ""), }); @@ -42,10 +39,7 @@ describe("selectRecordingMimeType", () => { it("falls back to the first supported codec when playback probing is unavailable", () => { const mimeType = selectRecordingMimeType({ isTypeSupported: (type) => - [ - "video/webm;codecs=av1", - "video/webm;codecs=h264", - ].includes(type), + ["video/webm;codecs=av1", "video/webm;codecs=h264"].includes(type), canPlayType: () => "", }); @@ -64,9 +58,7 @@ describe("selectRecordingMimeType", () => { it("prefers MP4/H.264 for webcam captures when supported", () => { const mimeType = selectWebcamRecordingMimeType({ isTypeSupported: (type) => - ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes( - type, - ), + ["video/mp4;codecs=avc1.42E01E", "video/webm;codecs=vp9"].includes(type), canPlayType: () => "probably", }); @@ -75,8 +67,7 @@ describe("selectRecordingMimeType", () => { it("falls back to WebM webcam capture when MP4 is unavailable", () => { const mimeType = selectWebcamRecordingMimeType({ - isTypeSupported: (type) => - ["video/webm;codecs=vp9", "video/webm"].includes(type), + isTypeSupported: (type) => ["video/webm;codecs=vp9", "video/webm"].includes(type), canPlayType: () => "probably", }); diff --git a/src/hooks/recordingMimeType.ts b/src/hooks/recordingMimeType.ts index 93a5ba82a..08850adb7 100644 --- a/src/hooks/recordingMimeType.ts +++ b/src/hooks/recordingMimeType.ts @@ -38,9 +38,7 @@ function selectMimeTypeFromPreferences( return playableType ?? supportedTypes[0]; } -export function selectRecordingMimeType( - options: MimeTypeSelectorOptions = {}, -): string | undefined { +export function selectRecordingMimeType(options: MimeTypeSelectorOptions = {}): string | undefined { return selectMimeTypeFromPreferences(RECORDING_MIME_TYPE_PREFERENCES, options); } @@ -54,6 +52,8 @@ export function isWebmMimeType(mimeType: string | undefined | null): boolean { return /^video\/webm(?:[;\s]|$)/i.test(mimeType ?? ""); } -export function getVideoExtensionForMimeType(mimeType: string | undefined | null): ".mp4" | ".webm" { +export function getVideoExtensionForMimeType( + mimeType: string | undefined | null, +): ".mp4" | ".webm" { return /^video\/mp4(?:[;\s]|$)/i.test(mimeType ?? "") ? ".mp4" : ".webm"; } diff --git a/src/i18n/locales/en/extensions.json b/src/i18n/locales/en/extensions.json index b1be4045e..e1556777d 100644 --- a/src/i18n/locales/en/extensions.json +++ b/src/i18n/locales/en/extensions.json @@ -57,4 +57,4 @@ "marketplaceInstallFailed": "Failed to install {{name}}", "enableFailed": "Failed to enable extension" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/pt-BR/extensions.json b/src/i18n/locales/pt-BR/extensions.json index 4f5dcbbaf..f7d8abc42 100644 --- a/src/i18n/locales/pt-BR/extensions.json +++ b/src/i18n/locales/pt-BR/extensions.json @@ -57,4 +57,4 @@ "marketplaceInstallFailed": "Falha ao instalar {{name}}", "enableFailed": "Falha ao ativar extensão" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 3946d72b9..8ea45bfbd 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -23,4 +23,4 @@ "failedToUploadImage": "Не удалось загрузить изображение", "fileReadError": "Произошла ошибка при чтении файла." } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/dialogs.json b/src/i18n/locales/ru/dialogs.json index da6ed8ee6..bb5c7512a 100644 --- a/src/i18n/locales/ru/dialogs.json +++ b/src/i18n/locales/ru/dialogs.json @@ -59,4 +59,4 @@ "cancel": "Отмена", "save": "Сохранить" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 673f27eec..36751cc9c 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -139,4 +139,4 @@ "collapse": "Свернуть таймлайн" }, "openRecordingsFolder": "Открыть папку с записями" -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/extensions.json b/src/i18n/locales/ru/extensions.json index 239fa9a3f..984d4dc0b 100644 --- a/src/i18n/locales/ru/extensions.json +++ b/src/i18n/locales/ru/extensions.json @@ -57,4 +57,4 @@ "marketplaceInstallFailed": "Не удалось установить {{name}}", "enableFailed": "Не удалось включить расширение" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/launch.json b/src/i18n/locales/ru/launch.json index 65bd57b8c..f341464c7 100644 --- a/src/i18n/locales/ru/launch.json +++ b/src/i18n/locales/ru/launch.json @@ -77,4 +77,4 @@ "failedToStart": "Не удалось начать запись: {{error}}", "failedToStartGeneric": "Не удалось начать запись" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/settings.json b/src/i18n/locales/ru/settings.json index 88e48ec5e..577745af7 100644 --- a/src/i18n/locales/ru/settings.json +++ b/src/i18n/locales/ru/settings.json @@ -202,4 +202,4 @@ "reportBug": "Сообщить об ошибке", "starOnGithub": "Оценить на GitHub" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/shortcuts.json b/src/i18n/locales/ru/shortcuts.json index 9898e48c3..3e54412bc 100644 --- a/src/i18n/locales/ru/shortcuts.json +++ b/src/i18n/locales/ru/shortcuts.json @@ -13,4 +13,4 @@ "panTimeline": "Прокрутка таймлайна", "zoomTimeline": "Масштабирование таймлайна" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ru/timeline.json b/src/i18n/locales/ru/timeline.json index 8c1d2b77a..5b89d4667 100644 --- a/src/i18n/locales/ru/timeline.json +++ b/src/i18n/locales/ru/timeline.json @@ -38,4 +38,4 @@ "addSpeed": "Скорость (S)", "resizeLeft": "Изменить размер слева", "resizeRight": "Изменить размер справа" -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index c5fa86d10..f65398523 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -23,4 +23,4 @@ "failedToUploadImage": "上傳圖片失敗", "fileReadError": "讀取檔案時出錯。" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/dialogs.json b/src/i18n/locales/zh-TW/dialogs.json index 828b70ee3..921299dff 100644 --- a/src/i18n/locales/zh-TW/dialogs.json +++ b/src/i18n/locales/zh-TW/dialogs.json @@ -59,4 +59,4 @@ "cancel": "取消", "save": "儲存" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index e37768b3f..6c4c62bdf 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -134,4 +134,4 @@ "collapse": "摺疊時間軸" }, "openRecordingsFolder": "打開錄製資料夾" -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/extensions.json b/src/i18n/locales/zh-TW/extensions.json index ae44234c7..cd85f835b 100644 --- a/src/i18n/locales/zh-TW/extensions.json +++ b/src/i18n/locales/zh-TW/extensions.json @@ -56,5 +56,5 @@ "marketplaceInstalled": "已安裝並啟用 {{name}}", "marketplaceInstallFailed": "安裝 {{name}} 失敗", "enableFailed": "啟用擴充功能失敗" - } } +} diff --git a/src/i18n/locales/zh-TW/launch.json b/src/i18n/locales/zh-TW/launch.json index 12ec494d0..9d70eedc0 100644 --- a/src/i18n/locales/zh-TW/launch.json +++ b/src/i18n/locales/zh-TW/launch.json @@ -44,15 +44,15 @@ "cancel": "取消", "more": "更多", "update": { - "update": "更新", - "updated": "已更新", - "idleTitle": "檢查更新。", - "checkingTitle": "正在檢查更新...", - "downloadingTitle": "正在下載更新...", - "errorTitle": "檢查更新失敗,點一下重試。", - "upToDateTitle": "Recordly {{version}} 已是最新版本。", - "availableTitle": "Recordly {{version}} 已推出新版本。", - "availableGenericTitle": "有可用更新。" + "update": "更新", + "updated": "已更新", + "idleTitle": "檢查更新。", + "checkingTitle": "正在檢查更新...", + "downloadingTitle": "正在下載更新...", + "errorTitle": "檢查更新失敗,點一下重試。", + "upToDateTitle": "Recordly {{version}} 已是最新版本。", + "availableTitle": "Recordly {{version}} 已推出新版本。", + "availableGenericTitle": "有可用更新。" } }, "sourceSelector": { diff --git a/src/i18n/locales/zh-TW/shortcuts.json b/src/i18n/locales/zh-TW/shortcuts.json index 8b1b31934..ea0b8a388 100644 --- a/src/i18n/locales/zh-TW/shortcuts.json +++ b/src/i18n/locales/zh-TW/shortcuts.json @@ -13,4 +13,4 @@ "panTimeline": "平移時間軸", "zoomTimeline": "縮放時間軸" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/zh-TW/timeline.json b/src/i18n/locales/zh-TW/timeline.json index 714fd29a4..0ddf65de6 100644 --- a/src/i18n/locales/zh-TW/timeline.json +++ b/src/i18n/locales/zh-TW/timeline.json @@ -38,4 +38,4 @@ "addSpeed": "新增速度(S)", "resizeLeft": "向左調整大小", "resizeRight": "向右調整大小" -} \ No newline at end of file +} diff --git a/src/index.css b/src/index.css index a5ae2368b..865b753e8 100644 --- a/src/index.css +++ b/src/index.css @@ -268,6 +268,3 @@ transform: translateX(270%); } } - - - diff --git a/src/lib/exporter/forwardFrameSource.ts b/src/lib/exporter/forwardFrameSource.ts index 34fbc1f29..37aff0f80 100644 --- a/src/lib/exporter/forwardFrameSource.ts +++ b/src/lib/exporter/forwardFrameSource.ts @@ -1,10 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; +import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource"; import { getDecodedFrameTimelineOffsetUs } from "./streamingDecoder"; -import { - createReadableMediaResourceFile, - resolveMediaResourceUrl, -} from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; diff --git a/src/lib/exporter/localMediaSource.test.ts b/src/lib/exporter/localMediaSource.test.ts index 883370ed3..cd22f935b 100644 --- a/src/lib/exporter/localMediaSource.test.ts +++ b/src/lib/exporter/localMediaSource.test.ts @@ -45,9 +45,7 @@ describe("resolveMediaElementSource", () => { expect((window as any).electronAPI.readLocalFile).not.toHaveBeenCalled(); expect((window as any).electronAPI.getLocalMediaUrl).not.toHaveBeenCalled(); - expect(result.src).toBe( - "http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4", - ); + expect(result.src).toBe("http://127.0.0.1:43123/video?path=%2Ftmp%2Fexample%20clip.mp4"); }); it("leaves remote URLs untouched", async () => { diff --git a/src/lib/exporter/localMediaSource.ts b/src/lib/exporter/localMediaSource.ts index 4f5a1b57a..76a2dc5af 100644 --- a/src/lib/exporter/localMediaSource.ts +++ b/src/lib/exporter/localMediaSource.ts @@ -137,9 +137,7 @@ export async function createReadableMediaResourceFile(resource: string): Promise const resourceUrl = await resolveMediaResourceUrl(resource); const response = await fetch(resourceUrl); if (!response.ok) { - throw new Error( - `Failed to load media resource: ${response.status} ${response.statusText}`, - ); + throw new Error(`Failed to load media resource: ${response.status} ${response.statusText}`); } const blob = await response.blob(); diff --git a/src/lib/exporter/mediaResource.test.ts b/src/lib/exporter/mediaResource.test.ts index 481617450..5d79c8a02 100644 --- a/src/lib/exporter/mediaResource.test.ts +++ b/src/lib/exporter/mediaResource.test.ts @@ -31,4 +31,4 @@ describe("getResourceFileName", () => { ), ).toBe("example video.mp4"); }); -}); \ No newline at end of file +}); diff --git a/src/lib/exporter/mediaResource.ts b/src/lib/exporter/mediaResource.ts index 83e385eb0..2f689dca1 100644 --- a/src/lib/exporter/mediaResource.ts +++ b/src/lib/exporter/mediaResource.ts @@ -108,4 +108,4 @@ export function getResourceFileName(resource: string, fallback: string): string } return fallback; -} \ No newline at end of file +} diff --git a/src/lib/exporter/streamingDecoder.test.ts b/src/lib/exporter/streamingDecoder.test.ts index de831671e..d272f7299 100644 --- a/src/lib/exporter/streamingDecoder.test.ts +++ b/src/lib/exporter/streamingDecoder.test.ts @@ -103,9 +103,7 @@ describe("StreamingVideoDecoder local media loading", () => { "http://127.0.0.1:4321/video?path=%2Ftmp%2Ffallback.mp4", ); expect(mockDemuxerLoad.mock.calls[1]?.[0]).toBeInstanceOf(File); - expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith( - "/tmp/fallback.mp4", - ); + expect((window as any).electronAPI.readLocalFile).toHaveBeenCalledWith("/tmp/fallback.mp4"); }); }); diff --git a/src/lib/exporter/streamingDecoder.ts b/src/lib/exporter/streamingDecoder.ts index e0f5a18ce..c35d1e972 100644 --- a/src/lib/exporter/streamingDecoder.ts +++ b/src/lib/exporter/streamingDecoder.ts @@ -1,10 +1,7 @@ import { WebDemuxer } from "web-demuxer"; import type { SpeedRegion, TrimRegion } from "@/components/video-editor/types"; import { getEffectiveVideoStreamDurationSeconds } from "@/lib/mediaTiming"; -import { - createReadableMediaResourceFile, - resolveMediaResourceUrl, -} from "./localMediaSource"; +import { createReadableMediaResourceFile, resolveMediaResourceUrl } from "./localMediaSource"; const DEFAULT_MAX_DECODE_QUEUE = 12; const DEFAULT_MAX_PENDING_FRAMES = 32; diff --git a/src/lib/exporter/temporalMotionBlur.test.ts b/src/lib/exporter/temporalMotionBlur.test.ts index 0ea1e30e6..b2a37f31c 100644 --- a/src/lib/exporter/temporalMotionBlur.test.ts +++ b/src/lib/exporter/temporalMotionBlur.test.ts @@ -75,5 +75,4 @@ describe("temporalMotionBlur", () => { expect(centerSample?.weight ?? 0).toBeGreaterThan(edgeSample?.weight ?? 0); expect(plan.map((sample) => sample.offsetUs)).toContain(0); }); - }); diff --git a/src/lib/shortcuts.ts b/src/lib/shortcuts.ts index d2d9828cd..23e2787f0 100644 --- a/src/lib/shortcuts.ts +++ b/src/lib/shortcuts.ts @@ -1,5 +1,6 @@ export const SHORTCUT_ACTIONS = [ "addZoom", + "addTrim", "splitClip", "addAnnotation", "addKeyframe", @@ -74,6 +75,7 @@ export function findConflict( export const DEFAULT_SHORTCUTS: ShortcutsConfig = { addZoom: { key: "z" }, + addTrim: { key: "t" }, splitClip: { key: "c" }, addAnnotation: { key: "a" }, addKeyframe: { key: "f" }, @@ -83,6 +85,7 @@ export const DEFAULT_SHORTCUTS: ShortcutsConfig = { export const SHORTCUT_LABELS: Record = { addZoom: "Add Zoom", + addTrim: "Add Trim", splitClip: "Split Clip", addAnnotation: "Add Annotation", addKeyframe: "Add Keyframe", diff --git a/src/lib/wallpapers.ts b/src/lib/wallpapers.ts index 1bddc4109..fc6e39e56 100644 --- a/src/lib/wallpapers.ts +++ b/src/lib/wallpapers.ts @@ -113,7 +113,10 @@ function toWallpaperLabel(fileName: string) { .replace(/\b\w/g, (match) => match.toUpperCase()); } -function createWallpaperEntry(fileName: string, label = toWallpaperLabel(fileName)): BuiltInWallpaper { +function createWallpaperEntry( + fileName: string, + label = toWallpaperLabel(fileName), +): BuiltInWallpaper { const encodedFileName = encodeURIComponent(fileName); return { id: toWallpaperId(fileName) || `wallpaper-${encodedFileName.toLowerCase()}`,