improve shift segments dialog

closes #2839
pull/2774/head
Mikael Finstad 1 month ago
parent 260530f11a
commit 2605303dde
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -195,7 +195,7 @@ function App() {
const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling();
const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog } = useDialog();
const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog } = useDialog();
// Note that each action may be multiple key bindings and this will only be the first binding for each action
const keyBindingByAction = useMemo(() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])), [keyBindings]);
@ -350,7 +350,7 @@ function App() {
}, [isFileOpened]);
const {
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter,
cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, modifySelectedSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, labelSegment, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, createNumSegments, createFixedDurationSegments, createFixedByteSizedSegments, createRandomSegments, getSegEstimatedSize, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, inverseCutSegments, clearSegments, clearSegColorCounter, loadCutSegments, setCutTime, setCurrentSegIndex, labelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, selectSegmentsByLabel, selectSegmentsByExpr, selectAllMarkers, mutateSegmentsByExpr, toggleSegmentSelected, selectOnlySegment, selectedSegments, segmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, findSegmentsAtCursor, maybeCreateFullLengthSegment, currentCutSegOrWholeTimeline, segColorCounter,
} = useSegments({ filePath, workingRef, setWorking, setProgress, videoStream: activeVideoStream, fileDuration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode, appendFfmpegCommandLog, fileDurationNonZero, mainFileMeta: mainFileMeta?.ffprobeMeta, seekAbs, activeVideoStreamIndex, activeAudioStreamIndexes, handleError, showGenericDialog, simpleMode, ffmpegHwaccel });
const { getEdlFilePath, projectFileSavePath, getProjectFileSavePath } = useSegmentsAutoSave({ autoSaveProjectFile, storeProjectInWorkingDir, filePath, customOutDir, cutSegments });
@ -1274,6 +1274,23 @@ function App() {
}
}, [filePath, workingRef, setWorking, withErrorHandling, getRelevantTime, videoRef, usingPreviewFile, captureFrameMethod, captureFrameFromFfmpeg, customOutDir, captureFormat, captureFrameQuality, captureFrameFromTag, simpleMode, prefersReducedMotion, hideAllNotifications, openExportFinishedDialog]);
const shiftAllSegmentTimes = useCallback(async () => {
const shift = await openShiftSegmentsDialog({ inputPlaceholder: timecodePlaceholder, parseTimecode });
if (shift == null) return;
const { startShift, endShift } = shift;
await modifySelectedSegmentTimes((segment) => {
const newSegment = { ...segment };
if (startShift != null) {
newSegment.start += startShift;
}
if (endShift != null && newSegment.end != null) {
newSegment.end += endShift;
}
return newSegment;
});
}, [modifySelectedSegmentTimes, openShiftSegmentsDialog, parseTimecode, timecodePlaceholder]);
const captureSnapshotToClipboard = useCallback(async () => {
if (!filePath || workingRef.current) return;
try {

@ -1,8 +1,9 @@
import type { MouseEventHandler, ReactNode } from 'react';
import type { FormEventHandler, MouseEventHandler, ReactNode } from 'react';
import React, { useCallback, useContext, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import invariant from 'tiny-invariant';
import { FaCheckCircle, FaInfoCircle } from 'react-icons/fa';
import { TextField } from '@radix-ui/themes';
import * as Dialog from './Dialog';
import * as AlertDialog from './AlertDialog';
@ -298,6 +299,83 @@ export function useDialog() {
});
}
const openShiftSegmentsDialog = useCallback(({ inputPlaceholder, parseTimecode }: {
inputPlaceholder: string,
parseTimecode: (s: string) => number | undefined,
}) => new Promise<{ startShift?: number, endShift?: number } | undefined>((resolve) => {
function ShiftDialog() {
const { onOpenChange } = useGenericDialogContext();
const [startValue, setStartValue] = useState('');
const [endValue, setEndValue] = useState('');
const parseValue = useCallback((value: string) => {
let parseableValue = value.trim();
if (!parseableValue) return undefined;
let sign = 1;
if (parseableValue[0] === '-') {
sign = -1;
parseableValue = parseableValue.slice(1);
}
const duration = parseTimecode(parseableValue);
invariant(duration != null);
if (duration === 0) return undefined;
return duration * sign;
}, []);
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((e) => {
e.preventDefault();
try {
const start = parseValue(startValue);
const end = parseValue(endValue);
// require at least one of them to be set
invariant(start != null || end != null);
const out: { startShift?: number, endShift?: number } = {};
if (start != null) out.startShift = start;
if (end != null) out.endShift = end;
resolve(out);
onOpenChange(false);
} catch (err) {
console.warn(err);
}
}, [parseValue, startValue, endValue, onOpenChange]);
return (
<Dialog.Content aria-describedby={undefined} style={{ width: '50vw' }}>
<Dialog.Title>{t('Shift segments')}</Dialog.Title>
<Dialog.Description>{t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.')}</Dialog.Description>
<form onSubmit={handleSubmit}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.3em' }}>
{t('Shift start by')}<br />
<TextField.Root value={startValue} placeholder={inputPlaceholder} onChange={(e) => setStartValue(e.target.value)} />
</label>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.3em' }}>
{t('Shift end by')}<br />
<TextField.Root value={endValue} placeholder={inputPlaceholder} onChange={(e) => setEndValue(e.target.value)} />
</label>
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</Dialog.Close>
<DialogButton type="submit" primary>{t('Confirm')}</DialogButton>
</Dialog.ButtonRow>
</form>
</Dialog.Content>
);
}
showGenericDialog({
render: () => <ShiftDialog />,
onClose: () => resolve(undefined),
});
}), [showGenericDialog, t]);
return {
genericDialog,
closeGenericDialog,
@ -307,6 +385,7 @@ export function useDialog() {
openCutFinishedDialog,
openConcatFinishedDialog,
openCleanupFilesDialog,
openShiftSegmentsDialog,
};
}

@ -293,50 +293,6 @@ async function askForSegmentsStartOrEnd(text: string) {
return value === 'both' ? ['start', 'end'] as const : [value as 'start' | 'end'] as const;
}
export async function askForShiftSegments({ inputPlaceholder, parseTimecode }: {
inputPlaceholder: string,
parseTimecode: ParseTimecode,
}) {
function parseValue(value: string) {
let parseableValue = value;
let sign = 1;
if (parseableValue[0] === '-') {
parseableValue = parseableValue.slice(1);
sign = -1;
}
const duration = parseTimecode(parseableValue);
if (duration != null && duration > 0) {
return duration * sign;
}
return undefined;
}
const { value } = await getSwal().Swal.fire<string>({
input: 'text',
showCancelButton: true,
inputValue: inputPlaceholder,
text: i18n.t('Shift all segments on the timeline by this amount. Negative values will be shifted back, while positive value will be shifted forward in time.'),
inputValidator: (v) => {
const parsed = parseValue(v);
if (parsed == null) return i18n.t('Please input a valid duration. Example: {{example}}', { example: inputPlaceholder });
return null;
},
});
if (value == null) return undefined;
const parsed = parseValue(value);
invariant(parsed != null);
const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to shift the start or end timestamp by {{time}}?', { time: formatDuration({ seconds: parsed, shorten: true }) }));
if (startOrEnd == null) return undefined;
return {
shiftAmount: parsed,
shiftKeys: startOrEnd,
};
}
export async function askForAlignSegments() {
const startOrEnd = await askForSegmentsStartOrEnd(i18n.t('Do you want to align the segment start or end timestamps to keyframes?'));
if (startOrEnd == null) return undefined;

@ -12,7 +12,7 @@ import TextInput from '../components/TextInput';
import { detectSceneChanges as ffmpegDetectSceneChanges, readFrames, mapTimesToSegments, findKeyframeNearTime } from '../ffmpeg';
import { getFileSize, shuffleArray } from '../util';
import { errorToast } from '../swal';
import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForShiftSegments, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs';
import { createNumSegments as createNumSegmentsDialog, createFixedByteSixedSegments as createFixedByteSixedSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, labelSegmentDialog, askForAlignSegments, selectSegmentsByLabelDialog, askForSegmentDuration, toastError } from '../dialogs';
import { createSegment, sortSegments, invertSegments, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, addSegmentColorIndex, filterNonMarkers, makeDurationSegments, isInitialSegment } from '../segments';
import type { FfmpegDialog } from '../ffmpegParameters';
import { parameters as allFfmpegParameters, getHint, getLabel } from '../ffmpegParameters';
@ -495,20 +495,6 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
safeSetCutSegments(newSegments, fileDuration);
}, [cutSegments, fileDuration, safeSetCutSegments]);
const shiftAllSegmentTimes = useCallback(async () => {
const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode });
if (shift == null) return;
const { shiftAmount, shiftKeys } = shift;
await modifySelectedSegmentTimes((segment) => {
const newSegment = { ...segment };
shiftKeys.forEach((key) => {
if (newSegment[key] != null) newSegment[key] += shiftAmount;
});
return newSegment;
});
}, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]);
const alignSegmentTimesToKeyframes = useCallback(async () => {
if (!videoStream || workingRef.current) return;
try {
@ -986,7 +972,7 @@ function useSegments({ filePath, workingRef, setWorking, setProgress, videoStrea
fillSegmentsGaps,
combineOverlappingSegments,
combineSelectedSegments,
shiftAllSegmentTimes,
modifySelectedSegmentTimes,
alignSegmentTimesToKeyframes,
updateSegOrder,
updateSegOrders,

Loading…
Cancel
Save