<kbd>shift</kbd> click/drag segment to move/resize

(configurable modifier key)

closes #517
#2595 #483
pull/2599/head
Mikael Finstad 9 months ago
parent 2511040f84
commit 2511045425
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -145,6 +145,7 @@ const defaults: Config = {
mouseWheelZoomModifierKey: 'ctrl', mouseWheelZoomModifierKey: 'ctrl',
mouseWheelFrameSeekModifierKey: 'alt', mouseWheelFrameSeekModifierKey: 'alt',
mouseWheelKeyframeSeekModifierKey: 'shift', mouseWheelKeyframeSeekModifierKey: 'shift',
segmentMouseModifierKey: 'shift',
captureFrameMethod: 'videotag', // we don't default to ffmpeg because ffmpeg might choose a frame slightly off captureFrameMethod: 'videotag', // we don't default to ffmpeg because ffmpeg might choose a frame slightly off
captureFrameQuality: 0.95, captureFrameQuality: 0.95,
captureFrameFileNameFormat: 'timestamp', captureFrameFileNameFormat: 'timestamp',

@ -2624,6 +2624,7 @@ function App() {
cutSegments={cutSegments} cutSegments={cutSegments}
setCurrentSegIndex={setCurrentSegIndex} setCurrentSegIndex={setCurrentSegIndex}
currentSegIndexSafe={currentSegIndexSafe} currentSegIndexSafe={currentSegIndexSafe}
currentCutSeg={currentCutSeg}
inverseCutSegments={inverseCutSegments} inverseCutSegments={inverseCutSegments}
formatTimecode={formatTimecode} formatTimecode={formatTimecode}
formatTimeAndFrames={formatTimeAndFrames} formatTimeAndFrames={formatTimeAndFrames}
@ -2636,6 +2637,7 @@ function App() {
onWheel={onTimelineWheel} onWheel={onTimelineWheel}
goToTimecode={goToTimecode} goToTimecode={goToTimecode}
darkMode={darkMode} darkMode={darkMode}
setCutTime={setCutTime}
/> />
<BottomBar <BottomBar
@ -2752,7 +2754,6 @@ function App() {
onKeyboardShortcutsDialogRequested={toggleKeyboardShortcuts} onKeyboardShortcutsDialogRequested={toggleKeyboardShortcuts}
askForCleanupChoices={askForCleanupChoices} askForCleanupChoices={askForCleanupChoices}
toggleStoreProjectInWorkingDir={toggleStoreProjectInWorkingDir} toggleStoreProjectInWorkingDir={toggleStoreProjectInWorkingDir}
simpleMode={simpleMode}
clearOutDir={clearOutDir} clearOutDir={clearOutDir}
/> />
<Dialog.CloseButton /> <Dialog.CloseButton />

@ -1,6 +1,6 @@
import { Fragment, memo, useMemo, useState } from 'react'; import { Fragment, memo, useMemo, useState } from 'react';
import { motion, MotionStyle } from 'framer-motion'; import { motion, MotionStyle } from 'framer-motion';
import { FaMouse } from 'react-icons/fa';
import { useTranslation, Trans } from 'react-i18next'; import { useTranslation, Trans } from 'react-i18next';
import SetCutpointButton from './components/SetCutpointButton'; import SetCutpointButton from './components/SetCutpointButton';
@ -48,7 +48,7 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy
keyBindingByAction: Record<string, KeyBinding>, keyBindingByAction: Record<string, KeyBinding>,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { simpleMode } = useUserSettings(); const { simpleMode, segmentMouseModifierKey } = useUserSettings();
const [dragging, setDragging] = useState(false); const [dragging, setDragging] = useState(false);
const currentCutSegOrDefault = useMemo(() => currentCutSeg ?? { segColorIndex: 0 }, [currentCutSeg]); const currentCutSegOrDefault = useMemo(() => currentCutSeg ?? { segColorIndex: 0 }, [currentCutSeg]);
@ -63,14 +63,14 @@ function NoFileLoaded({ mifiLink, currentCutSeg, onClick, darkMode, keyBindingBy
role="button" role="button"
onClick={onClick} onClick={onClick}
> >
<div style={{ fontSize: '2em', textTransform: 'uppercase', color: 'var(--gray-11)', marginBottom: '.2em' }}>{t('DROP FILE(S)')}</div> <div style={{ fontSize: '1.7em', textTransform: 'uppercase', color: 'var(--gray-11)', marginBottom: '.1em' }}>{t('DROP FILE(S)')}</div>
<div style={{ fontSize: '1.3em', color: 'var(--gray-11)', marginBottom: '.1em' }}> <div style={{ fontSize: '1.3em', color: 'var(--gray-11)', marginBottom: '.1em' }}>
<Trans>See <b>Help</b> menu for help</Trans> <Trans>See <b>Help</b> menu for help</Trans>
</div> </div>
<div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }}> <div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }}>
<Trans><SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" style={{ verticalAlign: 'middle' }} /> <SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="end" style={{ verticalAlign: 'middle' }} /> or <Keys keys={keyBindingByAction['setCutStart']?.keys} /> <Keys keys={keyBindingByAction['setCutEnd']?.keys} /> to set cutpoints</Trans> <Trans><SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="start" style={{ verticalAlign: 'middle' }} /> <SetCutpointButton currentCutSeg={currentCutSegOrDefault} side="end" style={{ verticalAlign: 'middle' }} />, <Keys keys={keyBindingByAction['setCutStart']?.keys} /> <Keys keys={keyBindingByAction['setCutEnd']?.keys} /> or <span><kbd style={{ marginRight: '.1em' }}>{segmentMouseModifierKey}</kbd></span>+<FaMouse style={{ marginRight: '.1em', verticalAlign: 'middle' }} /> to set cutpoints</Trans>
</div> </div>
<div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }} role="button" onClick={(e) => e.stopPropagation()}> <div style={{ fontSize: '1.3em', color: 'var(--gray-11)' }} role="button" onClick={(e) => e.stopPropagation()}>

@ -17,6 +17,8 @@ import { timelineBackground, darkModeTransition } from './colors';
import { Frame } from './ffmpeg'; import { Frame } from './ffmpeg';
import { FormatTimecode, InverseCutSegment, OverviewWaveform, RenderableWaveform, WaveformSlice, StateSegment, Thumbnail } from './types'; import { FormatTimecode, InverseCutSegment, OverviewWaveform, RenderableWaveform, WaveformSlice, StateSegment, Thumbnail } from './types';
import Button from './components/Button'; import Button from './components/Button';
import { UseSegments } from './hooks/useSegments';
import { keyMap } from './hooks/useTimelineScroll';
type CalculateTimelinePercent = (time: number) => string | undefined; type CalculateTimelinePercent = (time: number) => string | undefined;
@ -95,6 +97,7 @@ function Timeline({
cutSegments, cutSegments,
setCurrentSegIndex, setCurrentSegIndex,
currentSegIndexSafe, currentSegIndexSafe,
currentCutSeg,
inverseCutSegments, inverseCutSegments,
formatTimecode, formatTimecode,
formatTimeAndFrames, formatTimeAndFrames,
@ -116,6 +119,7 @@ function Timeline({
commandedTimeRef, commandedTimeRef,
goToTimecode, goToTimecode,
darkMode, darkMode,
setCutTime,
} : { } : {
fileDurationNonZero: number, fileDurationNonZero: number,
startTimeOffset: number, startTimeOffset: number,
@ -128,6 +132,7 @@ function Timeline({
cutSegments: StateSegment[], cutSegments: StateSegment[],
setCurrentSegIndex: (a: number) => void, setCurrentSegIndex: (a: number) => void,
currentSegIndexSafe: number, currentSegIndexSafe: number,
currentCutSeg: StateSegment | undefined,
inverseCutSegments: InverseCutSegment[], inverseCutSegments: InverseCutSegment[],
formatTimecode: FormatTimecode, formatTimecode: FormatTimecode,
formatTimeAndFrames: (a: number) => string, formatTimeAndFrames: (a: number) => string,
@ -149,10 +154,11 @@ function Timeline({
commandedTimeRef: MutableRefObject<number>, commandedTimeRef: MutableRefObject<number>,
goToTimecode: () => void, goToTimecode: () => void,
darkMode: boolean, darkMode: boolean,
setCutTime: UseSegments['setCutTime'];
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { invertCutSegments, springAnimation } = useUserSettings(); const { invertCutSegments, springAnimation, segmentMouseModifierKey } = useUserSettings();
const timelineScrollerRef = useRef<HTMLDivElement>(null); const timelineScrollerRef = useRef<HTMLDivElement>(null);
const timelineScrollerSkipEventRef = useRef<boolean>(false); const timelineScrollerSkipEventRef = useRef<boolean>(false);
@ -282,26 +288,63 @@ function Timeline({
const mouseDownRef = useRef<unknown>(); const mouseDownRef = useRef<unknown>();
const handleScrub = useCallback((e: MouseEvent) => seekAbs((getMouseTimelinePos(e))), [seekAbs, getMouseTimelinePos]);
useEffect(() => { useEffect(() => {
setHoveringTime(undefined); setHoveringTime(undefined);
}, [relevantTime]); }, [relevantTime]);
// for performance
const currentCutSegRef = useRef<StateSegment | undefined>(currentCutSeg);
useEffect(() => {
currentCutSegRef.current = currentCutSeg;
}, [currentCutSeg]);
const resizingSegmentRef = useRef<{ operation: 'start' | 'end' | 'move', offset?: number } | undefined>();
const onMouseDown = useCallback<MouseEventHandler<HTMLElement>>((e) => { const onMouseDown = useCallback<MouseEventHandler<HTMLElement>>((e) => {
if (e.nativeEvent.buttons !== 1) return; // not primary button if (e.nativeEvent.buttons !== 1) return; // not primary button
handleScrub(e.nativeEvent); const mouseTimelinePos = getMouseTimelinePos(e.nativeEvent);
seekAbs(mouseTimelinePos);
// eslint-disable-next-line no-shadow
const currentCutSeg = currentCutSegRef.current;
// start/end handles 1.5% of visible timeline
const threshold = ((0.01 / 2) * fileDurationNonZero) / zoom;
if (currentCutSeg != null && currentCutSeg.selected && e[keyMap[segmentMouseModifierKey]]) {
if (Math.abs(mouseTimelinePos - currentCutSeg.start) < threshold) {
resizingSegmentRef.current = { operation: currentCutSeg.end == null ? 'move' : 'start' }; // move marker or resize segment
} else if (currentCutSeg.end != null && Math.abs(mouseTimelinePos - currentCutSeg.end) < threshold) {
resizingSegmentRef.current = { operation: 'end' };
} else if (currentCutSeg.end != null && mouseTimelinePos >= currentCutSeg.start && mouseTimelinePos <= currentCutSeg.end) {
resizingSegmentRef.current = { operation: 'move', offset: mouseTimelinePos - currentCutSeg.start };
}
}
mouseDownRef.current = e.target; mouseDownRef.current = e.target;
function onMouseMove(e2: MouseEvent) { function onMouseMove(e2: MouseEvent) {
if (mouseDownRef.current == null) return; if (mouseDownRef.current == null) return;
seekAbs(getMouseTimelinePos(e2)); const mouseDragTimelinePos = getMouseTimelinePos(e2);
seekAbs(mouseDragTimelinePos);
try {
// eslint-disable-next-line unicorn/prefer-switch
if (resizingSegmentRef.current?.operation === 'start') {
setCutTime('start', mouseDragTimelinePos);
} else if (resizingSegmentRef?.current?.operation === 'end') {
setCutTime('end', mouseDragTimelinePos);
} else if (resizingSegmentRef?.current?.operation === 'move') {
setCutTime('move', mouseDragTimelinePos - (resizingSegmentRef.current.offset ?? 0));
}
} catch (err) {
console.warn('Error while resizing segment:', err instanceof Error ? err.message : err);
}
} }
function onMouseUp() { function onMouseUp() {
mouseDownRef.current = undefined; mouseDownRef.current = undefined;
resizingSegmentRef.current = undefined;
window.removeEventListener('mouseup', onMouseUp); window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('mousemove', onMouseMove); window.removeEventListener('mousemove', onMouseMove);
} }
@ -311,7 +354,7 @@ function Timeline({
// https://stackoverflow.com/questions/6073505/what-is-the-difference-between-screenx-y-clientx-y-and-pagex-y // https://stackoverflow.com/questions/6073505/what-is-the-difference-between-screenx-y-clientx-y-and-pagex-y
window.addEventListener('mouseup', onMouseUp, { once: true }); window.addEventListener('mouseup', onMouseUp, { once: true });
window.addEventListener('mousemove', onMouseMove); window.addEventListener('mousemove', onMouseMove);
}, [getMouseTimelinePos, handleScrub, seekAbs]); }, [fileDurationNonZero, getMouseTimelinePos, seekAbs, segmentMouseModifierKey, setCutTime, zoom]);
const timeRef = useRef<HTMLDivElement>(null); const timeRef = useRef<HTMLDivElement>(null);
const timeFadeTimeoutRef = useRef<NodeJS.Timeout>(); const timeFadeTimeoutRef = useRef<NodeJS.Timeout>();
@ -323,7 +366,7 @@ function Timeline({
const isInBounds = rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom; const isInBounds = rect && e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;
const showHide = (show: boolean) => timeRef.current?.style.setProperty('opacity', show ? '0.2' : '1'); const showHide = (show: boolean) => timeRef.current?.style.setProperty('opacity', show ? '0.2' : '1');
if (isInBounds != null) showHide(isInBounds); if (isInBounds != null) showHide(isInBounds);
console.log('isInBounds', isInBounds); // console.log('isInBounds', isInBounds);
// https://github.com/mifi/lossless-cut/issues/2592#issuecomment-3476211496 // https://github.com/mifi/lossless-cut/issues/2592#issuecomment-3476211496
if (timeFadeTimeoutRef.current) clearTimeout(timeFadeTimeoutRef.current); if (timeFadeTimeoutRef.current) clearTimeout(timeFadeTimeoutRef.current);

@ -169,7 +169,7 @@ const KeyboardShortcuts = memo(({
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey } = useUserSettings(); const { mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey } = useUserSettings();
const { actionsMap, extraLinesPerCategory } = useMemo(() => { const { actionsMap, extraLinesPerCategory } = useMemo(() => {
const playbackCategory = t('Playback'); const playbackCategory = t('Playback');
@ -749,13 +749,22 @@ const KeyboardShortcuts = memo(({
<WheelModifier key="4" text={t('Zoom in/out timeline')} wheelText={t('Mouse scroll/wheel up/down')} modifier={mouseWheelZoomModifierKey} />, <WheelModifier key="4" text={t('Zoom in/out timeline')} wheelText={t('Mouse scroll/wheel up/down')} modifier={mouseWheelZoomModifierKey} />,
], ],
[segmentsAndCutpointsCategory]: [
<div key="1" style={{ ...rowStyle, alignItems: 'center' }}>
<span>{t('Manipulate segments on timeline')}</span>
<div style={{ flexGrow: 1 }} />
<kbd style={{ marginRight: '.7em' }}>{segmentMouseModifierKey}</kbd>
<FaMouse style={{ marginRight: '.3em' }} />
<span>{t('Mouse click and drag')}</span>
</div>,
],
}; };
return { return {
extraLinesPerCategory, extraLinesPerCategory,
actionsMap, actionsMap,
}; };
}, [currentCutSeg, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, mouseWheelZoomModifierKey, t]); }, [currentCutSeg, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, mouseWheelZoomModifierKey, segmentMouseModifierKey, t]);
useEffect(() => { useEffect(() => {
// cleanup invalid bindings, to prevent renamed actions from blocking user to rebind // cleanup invalid bindings, to prevent renamed actions from blocking user to rebind

@ -17,6 +17,7 @@ import ButtonRaw, { ButtonProps } from './Button';
import { getModifierKeyNames } from '../hooks/useTimelineScroll'; import { getModifierKeyNames } from '../hooks/useTimelineScroll';
import { TunerType } from '../types'; import { TunerType } from '../types';
import Truncated from './Truncated'; import Truncated from './Truncated';
import { primaryColor } from '../colors';
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
const Button = ({ style, ...props }: ButtonProps) => <ButtonRaw style={{ padding: '.5em .9em', ...style }} {...props} />; const Button = ({ style, ...props }: ButtonProps) => <ButtonRaw style={{ padding: '.5em .9em', ...style }} {...props} />;
@ -56,20 +57,19 @@ function Settings({
onKeyboardShortcutsDialogRequested, onKeyboardShortcutsDialogRequested,
askForCleanupChoices, askForCleanupChoices,
toggleStoreProjectInWorkingDir, toggleStoreProjectInWorkingDir,
simpleMode,
clearOutDir, clearOutDir,
}: { }: {
onTunerRequested: (type: TunerType) => void, onTunerRequested: (type: TunerType) => void,
onKeyboardShortcutsDialogRequested: () => void, onKeyboardShortcutsDialogRequested: () => void,
askForCleanupChoices: () => Promise<unknown>, askForCleanupChoices: () => Promise<unknown>,
toggleStoreProjectInWorkingDir: () => Promise<void>, toggleStoreProjectInWorkingDir: () => Promise<void>,
simpleMode: boolean,
clearOutDir: () => Promise<void>, clearOutDir: () => Promise<void>,
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAdvanced, setShowAdvanced] = useState(!simpleMode);
const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion } = useUserSettings(); const { customOutDir, changeOutDir, keyframeCut, toggleKeyframeCut, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, autoSaveProjectFile, setAutoSaveProjectFile, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, hideOsNotifications, setHideOsNotifications, autoLoadTimecode, setAutoLoadTimecode, enableAutoHtml5ify, setEnableAutoHtml5ify, customFfPath, setCustomFfPath, storeProjectInWorkingDir, mouseWheelZoomModifierKey, setMouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey, segmentMouseModifierKey, setSegmentMouseModifierKey, captureFrameMethod, setCaptureFrameMethod, captureFrameQuality, setCaptureFrameQuality, captureFrameFileNameFormat, setCaptureFrameFileNameFormat, enableNativeHevc, setEnableNativeHevc, enableUpdateCheck, setEnableUpdateCheck, allowMultipleInstances, setAllowMultipleInstances, preferStrongColors, setPreferStrongColors, treatInputFileModifiedTimeAsStart, setTreatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, setTreatOutputFileModifiedTimeAsStart, exportConfirmEnabled, toggleExportConfirmEnabled, storeWindowBounds, setStoreWindowBounds, reducedMotion, setReducedMotion, simpleMode } = useUserSettings();
const [showAdvanced, setShowAdvanced] = useState(!simpleMode);
const onLangChange = useCallback<ChangeEventHandler<HTMLSelectElement>>((e) => { const onLangChange = useCallback<ChangeEventHandler<HTMLSelectElement>>((e) => {
const { value } = e.target; const { value } = e.target;
@ -227,7 +227,7 @@ function Settings({
</KeyCell> </KeyCell>
<td> <td>
<Button onClick={() => setInvertCutSegments((v) => !v)}> <Button onClick={() => setInvertCutSegments((v) => !v)}>
<FaYinYang style={{ verticalAlign: 'middle', marginRight: '.3em' }} /> {invertCutSegments ? t('Remove') : t('Keep')} <FaYinYang style={{ verticalAlign: 'middle', marginRight: '.3em', color: invertCutSegments ? primaryColor : undefined }} /> {invertCutSegments ? t('Remove') : t('Keep')}
</Button> </Button>
</td> </td>
</Row> </Row>
@ -380,6 +380,7 @@ function Settings({
</td> </td>
</Row> </Row>
<ModifierKeySetting text={t('Segment manipulation mouse modifier key')} value={segmentMouseModifierKey} setValue={setSegmentMouseModifierKey} />
<ModifierKeySetting text={t('Mouse wheel zoom modifier key')} value={mouseWheelZoomModifierKey} setValue={setMouseWheelZoomModifierKey} /> <ModifierKeySetting text={t('Mouse wheel zoom modifier key')} value={mouseWheelZoomModifierKey} setValue={setMouseWheelZoomModifierKey} />
<ModifierKeySetting text={t('Mouse wheel frame seek modifier key')} value={mouseWheelFrameSeekModifierKey} setValue={setMouseWheelFrameSeekModifierKey} /> <ModifierKeySetting text={t('Mouse wheel frame seek modifier key')} value={mouseWheelFrameSeekModifierKey} setValue={setMouseWheelFrameSeekModifierKey} />
<ModifierKeySetting text={t('Mouse wheel keyframe seek modifier key')} value={mouseWheelKeyframeSeekModifierKey} setValue={setMouseWheelKeyframeSeekModifierKey} /> <ModifierKeySetting text={t('Mouse wheel keyframe seek modifier key')} value={mouseWheelKeyframeSeekModifierKey} setValue={setMouseWheelKeyframeSeekModifierKey} />

@ -441,21 +441,31 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
safeSetCutSegments(cutSegmentsNew, fileDuration); safeSetCutSegments(cutSegmentsNew, fileDuration);
}, [cutSegments, safeSetCutSegments, fileDuration]); }, [cutSegments, safeSetCutSegments, fileDuration]);
const setCutTime = useCallback((type: 'start' | 'end', time: number | undefined) => { const setCutTime = useCallback((type: 'start' | 'end' | 'move', time: number | undefined) => {
if (!isDurationValid(fileDuration) || currentCutSeg == null) return; if (!isDurationValid(fileDuration) || currentCutSeg == null) return;
const clampStart = (start: number) => Math.min(Math.max(start, 0), fileDuration);
const clampEnd = (end?: number | undefined) => (end != null ? Math.min(Math.max(end, 0), fileDuration) : undefined);
if (type === 'start') { if (type === 'start') {
invariant(time != null); invariant(time != null);
if (currentCutSeg.end != null && time >= currentCutSeg.end) { if (currentCutSeg.end != null && time >= currentCutSeg.end) {
throw new Error('Start time must precede end time'); throw new UserFacingError(i18n.t('Segment start time must precede end time'));
} }
updateSegAtIndex(currentSegIndexSafe, { start: Math.min(Math.max(time, 0), fileDuration) }); updateSegAtIndex(currentSegIndexSafe, { start: clampStart(time) });
} }
if (type === 'end') { if (type === 'end') {
if (time != null && time <= currentCutSeg.start) { if (time != null && time <= currentCutSeg.start) {
throw new Error('Start time must precede end time'); throw new UserFacingError(i18n.t('Segment start time must precede end time'));
} }
updateSegAtIndex(currentSegIndexSafe, { end: time != null ? Math.min(Math.max(time, 0), fileDuration) : undefined }); updateSegAtIndex(currentSegIndexSafe, { end: clampEnd(time) });
}
if (type === 'move') {
invariant(time != null);
updateSegAtIndex(currentSegIndexSafe, {
start: clampStart(time),
...(currentCutSeg.end != null && { end: clampEnd(time + (currentCutSeg.end - currentCutSeg.start)) }),
});
} }
}, [currentSegIndexSafe, currentCutSeg, fileDuration, updateSegAtIndex]); }, [currentSegIndexSafe, currentCutSeg, fileDuration, updateSegAtIndex]);

@ -153,6 +153,8 @@ export default function useUserSettingsRoot() {
useEffect(() => safeSetConfig({ mouseWheelFrameSeekModifierKey }), [mouseWheelFrameSeekModifierKey]); useEffect(() => safeSetConfig({ mouseWheelFrameSeekModifierKey }), [mouseWheelFrameSeekModifierKey]);
const [mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey] = useState(safeGetConfigInitial('mouseWheelKeyframeSeekModifierKey')); const [mouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey] = useState(safeGetConfigInitial('mouseWheelKeyframeSeekModifierKey'));
useEffect(() => safeSetConfig({ mouseWheelKeyframeSeekModifierKey }), [mouseWheelKeyframeSeekModifierKey]); useEffect(() => safeSetConfig({ mouseWheelKeyframeSeekModifierKey }), [mouseWheelKeyframeSeekModifierKey]);
const [segmentMouseModifierKey, setSegmentMouseModifierKey] = useState(safeGetConfigInitial('segmentMouseModifierKey'));
useEffect(() => safeSetConfig({ segmentMouseModifierKey }), [segmentMouseModifierKey]);
const [captureFrameMethod, setCaptureFrameMethod] = useState(safeGetConfigInitial('captureFrameMethod')); const [captureFrameMethod, setCaptureFrameMethod] = useState(safeGetConfigInitial('captureFrameMethod'));
useEffect(() => safeSetConfig({ captureFrameMethod }), [captureFrameMethod]); useEffect(() => safeSetConfig({ captureFrameMethod }), [captureFrameMethod]);
const [captureFrameQuality, setCaptureFrameQuality] = useState(safeGetConfigInitial('captureFrameQuality')); const [captureFrameQuality, setCaptureFrameQuality] = useState(safeGetConfigInitial('captureFrameQuality'));
@ -269,6 +271,7 @@ export default function useUserSettingsRoot() {
mouseWheelZoomModifierKey, mouseWheelZoomModifierKey,
mouseWheelFrameSeekModifierKey, mouseWheelFrameSeekModifierKey,
mouseWheelKeyframeSeekModifierKey, mouseWheelKeyframeSeekModifierKey,
segmentMouseModifierKey,
captureFrameMethod, captureFrameMethod,
captureFrameQuality, captureFrameQuality,
captureFrameFileNameFormat, captureFrameFileNameFormat,
@ -343,6 +346,7 @@ export default function useUserSettingsRoot() {
setMouseWheelZoomModifierKey, setMouseWheelZoomModifierKey,
setMouseWheelFrameSeekModifierKey, setMouseWheelFrameSeekModifierKey,
setMouseWheelKeyframeSeekModifierKey, setMouseWheelKeyframeSeekModifierKey,
setSegmentMouseModifierKey,
setCaptureFrameMethod, setCaptureFrameMethod,
setCaptureFrameQuality, setCaptureFrameQuality,
setCaptureFrameFileNameFormat, setCaptureFrameFileNameFormat,

@ -106,6 +106,7 @@ export interface Config {
mouseWheelZoomModifierKey: ModifierKey, mouseWheelZoomModifierKey: ModifierKey,
mouseWheelFrameSeekModifierKey: ModifierKey, mouseWheelFrameSeekModifierKey: ModifierKey,
mouseWheelKeyframeSeekModifierKey: ModifierKey, mouseWheelKeyframeSeekModifierKey: ModifierKey,
segmentMouseModifierKey: ModifierKey,
captureFrameMethod: 'videotag' | 'ffmpeg', captureFrameMethod: 'videotag' | 'ffmpeg',
captureFrameQuality: number, captureFrameQuality: number,
captureFrameFileNameFormat: 'timestamp' | 'index', captureFrameFileNameFormat: 'timestamp' | 'index',

Loading…
Cancel
Save