From de9e927d26bbc1f7012df047fbcb6abff0b51a8b Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Wed, 10 Apr 2024 22:08:33 +0200 Subject: [PATCH] implement consistent duration format #1960 --- src/renderer/src/App.tsx | 84 ++++++++++++++++---------- src/renderer/src/BottomBar.jsx | 19 +++--- src/renderer/src/StreamsSelector.jsx | 8 ++- src/renderer/src/dialogs/index.tsx | 38 ++++++------ src/renderer/src/hooks/useSegments.ts | 14 ++--- src/renderer/src/types.ts | 1 + src/renderer/src/util/duration.test.ts | 4 ++ src/renderer/src/util/duration.ts | 18 ++++-- 8 files changed, 114 insertions(+), 72 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e4bb11c2..febe57ff 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -70,10 +70,10 @@ import { isStoreBuild, dragPreventer, havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, deleteFiles, isOutOfSpaceError, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType, - calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, + calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration, } from './util'; import { toast, errorToast } from './swal'; -import { formatDuration } from './util/duration'; +import { formatDuration, parseDuration } from './util/duration'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; import { askForHtml5ifySpeed } from './dialogs/html5ify'; @@ -86,7 +86,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti import BigWaveform from './components/BigWaveform'; import isDev from './isDev'; -import { ChromiumHTMLVideoElement, EdlFileType, FfmpegCommandLog, FormatTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; +import { ChromiumHTMLVideoElement, EdlFileType, FfmpegCommandLog, FormatTimecode, ParseTimecode, PlaybackMode, SegmentColorIndex, SegmentTags, SegmentToExport, StateSegment, Thumbnail, TunerType } from './types'; import { CaptureFormat, KeyboardAction, Html5ifyMode } from '../../../types'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe'; @@ -368,9 +368,46 @@ function App() { return false; }, [isFileOpened]); + const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]); + const frameCountToDuration = useCallback((frames: number) => getFrameDuration(detectedFps) * frames, [detectedFps]); + + const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { + if (timecodeFormat === 'frameCount') { + const frameCount = getFrameCount(seconds); + return frameCount != null ? String(frameCount) : ''; + } + if (timecodeFormat === 'timecodeWithFramesFraction') { + return formatDuration({ seconds, shorten, fileNameFriendly, fps: detectedFps }); + } + return formatDuration({ seconds, shorten, fileNameFriendly }); + }, [detectedFps, timecodeFormat, getFrameCount]); + + const timecodePlaceholder = useMemo(() => formatTimecode({ seconds: 0, shorten: false }), [formatTimecode]); + + const parseTimecode = useCallback((val: string) => { + if (timecodeFormat === 'frameCount') { + const parsed = parseInt(val, 10); + return frameCountToDuration(parsed); + } + if (timecodeFormat === 'timecodeWithFramesFraction') { + return parseDuration(val, detectedFps); + } + return parseDuration(val); + }, [detectedFps, frameCountToDuration, timecodeFormat]); + + const formatTimeAndFrames = useCallback((seconds: number) => { + const frameCount = getFrameCount(seconds); + + const timeStr = timecodeFormat === 'timecodeWithFramesFraction' + ? formatDuration({ seconds, fps: detectedFps }) + : formatDuration({ seconds }); + + return `${timeStr} (${frameCount ?? '0'})`; + }, [detectedFps, timecodeFormat, getFrameCount]); + const { cutSegments, cutSegmentsHistory, createSegmentsFromKeyframes, shuffleSegments, detectBlackScenes, detectSilentScenes, detectSceneChanges, removeCutSegment, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, shiftAllSegmentTimes, alignSegmentTimesToKeyframes, updateSegOrder, updateSegOrders, reorderSegsByStartTime, addSegment, setCutStart, setCutEnd, onLabelSegment, splitCurrentSegment, createNumSegments, createFixedDurationSegments, createRandomSegments, apparentCutSegments, haveInvalidSegs, currentSegIndexSafe, currentCutSeg, currentApparentCutSeg, inverseCutSegments, clearSegments, loadCutSegments, isSegmentSelected, setCutTime, setCurrentSegIndex, onLabelSelectedSegments, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, setDeselectedSegmentIds, onSelectSegmentsByLabel, onSelectSegmentsByTag, toggleSegmentSelected, selectOnlySegment, getApparentCutSegmentById, selectedSegments, selectedSegmentsOrInverse, nonFilteredSegmentsOrInverse, segmentsToExport, duplicateCurrentSegment, duplicateSegment, updateSegAtIndex, - } = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }); + } = useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream: activeVideoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode }); const segmentAtCursor = useMemo(() => { @@ -416,30 +453,6 @@ function App() { const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]); const jumpTimelineEnd = useCallback(() => userSeekAbs(durationSafe), [durationSafe, userSeekAbs]); - - const getFrameCount = useCallback((sec: number) => getFrameCountRaw(detectedFps, sec), [detectedFps]); - - const formatTimecode = useCallback(({ seconds, shorten, fileNameFriendly }) => { - if (timecodeFormat === 'frameCount') { - const frameCount = getFrameCount(seconds); - return frameCount != null ? String(frameCount) : ''; - } - if (timecodeFormat === 'timecodeWithFramesFraction') { - return formatDuration({ seconds, fps: detectedFps, shorten, fileNameFriendly }); - } - return formatDuration({ seconds, shorten, fileNameFriendly }); - }, [detectedFps, timecodeFormat, getFrameCount]); - - const formatTimeAndFrames = useCallback((seconds: number) => { - const frameCount = getFrameCount(seconds); - - const timeStr = timecodeFormat === 'timecodeWithFramesFraction' - ? formatDuration({ seconds, fps: detectedFps }) - : formatDuration({ seconds }); - - return `${timeStr} (${frameCount ?? '0'})`; - }, [detectedFps, timecodeFormat, getFrameCount]); - const { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode, treatOutputFileModifiedTimeAsStart }); // const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]); @@ -1703,14 +1716,16 @@ function App() { const goToTimecode = useCallback(async () => { if (!filePath) return; const timeCode = await promptTimeOffset({ - initialValue: formatDuration({ seconds: commandedTimeRef.current }), + initialValue: formatTimecode({ seconds: commandedTimeRef.current }), title: i18n.t('Seek to timecode'), + inputPlaceholder: timecodePlaceholder, + parseTimecode, }); if (timeCode === undefined) return; userSeekAbs(timeCode); - }, [filePath, userSeekAbs]); + }, [filePath, formatTimecode, parseTimecode, timecodePlaceholder, userSeekAbs]); const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []); @@ -1776,15 +1791,17 @@ function App() { const askStartTimeOffset = useCallback(async () => { const newStartTimeOffset = await promptTimeOffset({ - initialValue: startTimeOffset !== undefined ? formatDuration({ seconds: startTimeOffset }) : undefined, + initialValue: startTimeOffset !== undefined ? formatTimecode({ seconds: startTimeOffset }) : undefined, title: i18n.t('Set custom start time offset'), text: i18n.t('Instead of video apparently starting at 0, you can offset by a specified value. This only applies to the preview inside LosslessCut and does not modify the file in any way. (Useful for viewing/cutting videos according to timecodes)'), + inputPlaceholder: timecodePlaceholder, + parseTimecode, }); if (newStartTimeOffset === undefined) return; setStartTimeOffset(newStartTimeOffset); - }, [startTimeOffset]); + }, [formatTimecode, parseTimecode, startTimeOffset, timecodePlaceholder]); const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []); @@ -2701,6 +2718,8 @@ function App() { setDarkMode={setDarkMode} outputPlaybackRate={outputPlaybackRate} setOutputPlaybackRate={setOutputPlaybackRate} + formatTimecode={formatTimecode} + parseTimecode={parseTimecode} /> @@ -2731,6 +2750,7 @@ function App() { setCustomTagsByFile={setCustomTagsByFile} paramsByStreamId={paramsByStreamId} updateStreamParams={updateStreamParams} + formatTimecode={formatTimecode} /> )} diff --git a/src/renderer/src/BottomBar.jsx b/src/renderer/src/BottomBar.jsx index 70d334b3..b72d3dd5 100644 --- a/src/renderer/src/BottomBar.jsx +++ b/src/renderer/src/BottomBar.jsx @@ -20,7 +20,7 @@ import { withBlur, mirrorTransform, checkAppPath } from './util'; import { toast } from './swal'; import { getSegColor as getSegColorRaw } from './util/colors'; import { useSegColors } from './contexts'; -import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration'; +import { isExactDurationMatch } from './util/duration'; import useUserSettings from './hooks/useUserSettings'; import { askForPlaybackRate } from './dialogs'; @@ -63,7 +63,7 @@ const InvertCutModeButton = memo(({ invertCutSegments, setInvertCutSegments }) = }); -const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, currentApparentCutSeg, isStart }) => { +const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, seekAbs, currentCutSeg, currentApparentCutSeg, isStart, formatTimecode, parseTimecode }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -102,19 +102,19 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see e.preventDefault(); // Don't proceed if not a valid time value - const timeWithOffset = parseDuration(cutTimeManual); + const timeWithOffset = parseTimecode(cutTimeManual); if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); - }, [cutTimeManual, trySetTime]); + }, [cutTimeManual, parseTimecode, trySetTime]); const parseAndSetCutTime = useCallback((text) => { // Don't proceed if not a valid time value - const timeWithOffset = parseDuration(text); + const timeWithOffset = parseTimecode(text); if (timeWithOffset === undefined) return; trySetTime(timeWithOffset); - }, [trySetTime]); + }, [parseTimecode, trySetTime]); function handleCutTimeInput(text) { setCutTimeManual(text); @@ -160,7 +160,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see onContextMenu={handleContextMenu} value={isCutTimeManualSet() ? cutTimeManual - : formatDuration({ seconds: cutTime + startTimeOffset })} + : formatTimecode({ seconds: cutTime + startTimeOffset })} /> ); @@ -178,6 +178,7 @@ const BottomBar = memo(({ darkMode, setDarkMode, toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails, outputPlaybackRate, setOutputPlaybackRate, + formatTimecode, parseTimecode, }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -308,7 +309,7 @@ const BottomBar = memo(({ - {!simpleMode && } + {!simpleMode && } seekClosestKeyframe(1)} /> - {!simpleMode && } + {!simpleMode && } diff --git a/src/renderer/src/StreamsSelector.jsx b/src/renderer/src/StreamsSelector.jsx index 44ab5dc2..a392f70a 100644 --- a/src/renderer/src/StreamsSelector.jsx +++ b/src/renderer/src/StreamsSelector.jsx @@ -10,7 +10,6 @@ import prettyBytes from 'pretty-bytes'; import AutoExportToggler from './components/AutoExportToggler'; import Select from './components/Select'; import { showJson5Dialog } from './dialogs'; -import { formatDuration } from './util/duration'; import { getStreamFps } from './ffmpeg'; import { deleteDispositionValue } from './util'; import { getActiveDisposition, attachedPicDisposition } from './util/streams'; @@ -131,7 +130,7 @@ function onInfoClick(json, title) { showJson5Dialog({ title, json }); } -const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams }) => { +const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copyStream, fileDuration, setEditingStream, onExtractStreamPress, paramsByStreamId, updateStreamParams, formatTimecode }) => { const { t } = useTranslation(); const effectiveDisposition = useMemo(() => getStreamEffectiveDisposition(paramsByStreamId, filePath, stream), [filePath, paramsByStreamId, stream]); @@ -192,7 +191,7 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt {stream.codec_name} {codecTag} - {!Number.isNaN(duration) && `${formatDuration({ seconds: duration, shorten: true })}`} + {!Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`} {stream.nb_frames != null ?
{stream.nb_frames}f
: null} {!Number.isNaN(bitrate) && (stream.codec_type === 'audio' ? `${Math.round(bitrate / 1000)} kbps` : prettyBytes(bitrate, { bits: true }))} @@ -297,6 +296,7 @@ const StreamsSelector = memo(({ setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta, showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams, customTagsByFile, setCustomTagsByFile, paramsByStreamId, updateStreamParams, + formatTimecode, }) => { const [editingFile, setEditingFile] = useState(); const [editingStream, setEditingStream] = useState(); @@ -360,6 +360,7 @@ const StreamsSelector = memo(({ onExtractStreamPress={() => onExtractStreamPress(stream.index)} paramsByStreamId={paramsByStreamId} updateStreamParams={updateStreamParams} + formatTimecode={formatTimecode} /> ))} @@ -385,6 +386,7 @@ const StreamsSelector = memo(({ fileDuration={getFormatDuration(formatData)} paramsByStreamId={paramsByStreamId} updateStreamParams={updateStreamParams} + formatTimecode={formatTimecode} /> ))} diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index a6a631eb..0001f809 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -8,18 +8,18 @@ import { tomorrow as syntaxStyle } from 'react-syntax-highlighter/dist/esm/style import JSON5 from 'json5'; import { SweetAlertOptions } from 'sweetalert2'; -import { parseDuration, formatDuration } from '../util/duration'; +import { formatDuration } from '../util/duration'; import Swal, { swalToastOptions, toast } from '../swal'; import { parseYouTube } from '../edlFormats'; import CopyClipboardButton from '../components/CopyClipboardButton'; import { isWindows, showItemInFolder } from '../util'; -import { SegmentBase } from '../types'; +import { ParseTimecode, SegmentBase } from '../types'; const { dialog } = window.require('@electron/remote'); const ReactSwal = withReactContent(Swal); -export async function promptTimeOffset({ initialValue, title, text }: { initialValue?: string | undefined, title: string, text?: string | undefined }) { +export async function promptTimeOffset({ initialValue, title, text, inputPlaceholder, parseTimecode }: { initialValue?: string | undefined, title: string, text?: string | undefined, inputPlaceholder: string, parseTimecode: ParseTimecode }) { // @ts-expect-error todo const { value } = await Swal.fire({ title, @@ -27,16 +27,16 @@ export async function promptTimeOffset({ initialValue, title, text }: { initialV input: 'text', inputValue: initialValue || '', showCancelButton: true, - inputPlaceholder: '00:00:00.000', + inputPlaceholder, }); if (value === undefined) { return undefined; } - const duration = parseDuration(value); + const duration = parseTimecode(value); // Invalid, try again - if (duration === undefined) return promptTimeOffset({ initialValue: value, title, text }); + if (duration === undefined) return promptTimeOffset({ initialValue: value, title, text, inputPlaceholder, parseTimecode }); return duration; } @@ -200,25 +200,27 @@ export async function createNumSegments(fileDuration) { const exampleDuration = '00:00:05.123'; -async function askForSegmentDuration(fileDuration) { +async function askForSegmentDuration({ fileDuration, inputPlaceholder, parseTimecode }: { + fileDuration: number, inputPlaceholder: string, parseTimecode: ParseTimecode, +}) { const { value } = await Swal.fire({ input: 'text', showCancelButton: true, - inputValue: '00:00:00.000', + inputValue: inputPlaceholder, text: i18n.t('Divide timeline into a number of segments with the specified length'), inputValidator: (v) => { - const duration = parseDuration(v); + const duration = parseTimecode(v); if (duration != null) { const numSegments = Math.ceil(fileDuration / duration); if (duration > 0 && duration < fileDuration && numSegments <= maxSegments) return null; } - return i18n.t('Please input a valid duration. Example: {{example}}', { example: exampleDuration }); + return i18n.t('Please input a valid duration. Example: {{example}}', { example: inputPlaceholder }); }, }); if (value == null) return undefined; - return parseDuration(value); + return parseTimecode(value); } // https://github.com/mifi/lossless-cut/issues/1153 @@ -273,15 +275,15 @@ async function askForSegmentsStartOrEnd(text) { return value === 'both' ? ['start', 'end'] : [value]; } -export async function askForShiftSegments() { - function parseValue(value) { +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 = parseDuration(parseableValue); + const duration = parseTimecode(parseableValue); if (duration != null && duration > 0) { return duration * sign; } @@ -291,7 +293,7 @@ export async function askForShiftSegments() { const { value } = await Swal.fire({ input: 'text', showCancelButton: true, - inputValue: '00:00:00.000', + 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); @@ -417,8 +419,10 @@ export async function showCleanupFilesDialog(cleanupChoicesIn) { return undefined; } -export async function createFixedDurationSegments(fileDuration) { - const segmentDuration = await askForSegmentDuration(fileDuration); +export async function createFixedDurationSegments({ fileDuration, inputPlaceholder, parseTimecode }: { + fileDuration: number, inputPlaceholder: string, parseTimecode: ParseTimecode, +}) { + const segmentDuration = await askForSegmentDuration({ fileDuration, inputPlaceholder, parseTimecode }); if (segmentDuration == null) return undefined; const edl: SegmentBase[] = []; for (let start = 0; start < fileDuration; start += segmentDuration) { diff --git a/src/renderer/src/hooks/useSegments.ts b/src/renderer/src/hooks/useSegments.ts index 4761e791..d0f70f9c 100644 --- a/src/renderer/src/hooks/useSegments.ts +++ b/src/renderer/src/hooks/useSegments.ts @@ -14,13 +14,13 @@ import { createNumSegments as createNumSegmentsDialog, createFixedDurationSegmen import { createSegment, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, combineOverlappingSegments as combineOverlappingSegments2, combineSelectedSegments as combineSelectedSegments2, isDurationValid, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, addSegmentColorIndex } from '../segments'; import * as ffmpegParameters from '../ffmpeg-parameters'; import { maxSegmentsAllowed } from '../util/constants'; -import { SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types'; +import { ParseTimecode, SegmentBase, SegmentToExport, StateSegment, UpdateSegAtIndex } from '../types'; const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js'); -function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }: { - filePath?: string | undefined, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number | undefined, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean, +function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly, timecodePlaceholder, parseTimecode }: { + filePath?: string | undefined, workingRef: MutableRefObject, setWorking: (w: { text: string, abortController?: AbortController } | undefined) => void, setCutProgress: (a: number | undefined) => void, videoStream, duration?: number | undefined, getRelevantTime: () => number, maxLabelLength: number, checkFileOpened: () => boolean, invertCutSegments: boolean, segmentsToChaptersOnly: boolean, timecodePlaceholder: string, parseTimecode: ParseTimecode, }) { // Segment related state const segCounterRef = useRef(0); @@ -259,7 +259,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt }, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]); const shiftAllSegmentTimes = useCallback(async () => { - const shift = await askForShiftSegments(); + const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode }); if (shift == null) return; const { shiftAmount, shiftKeys } = shift; @@ -270,7 +270,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt }); return newSegment; }); - }, [modifySelectedSegmentTimes]); + }, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]); const alignSegmentTimesToKeyframes = useCallback(async () => { if (!videoStream || workingRef.current) return; @@ -444,9 +444,9 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt const createFixedDurationSegments = useCallback(async () => { if (!checkFileOpened() || !isDurationValid(duration)) return; - const segments = await createFixedDurationSegmentsDialog(duration); + const segments = await createFixedDurationSegmentsDialog({ fileDuration: duration, inputPlaceholder: timecodePlaceholder, parseTimecode }); if (segments) loadCutSegments(segments); - }, [checkFileOpened, duration, loadCutSegments]); + }, [checkFileOpened, duration, loadCutSegments, parseTimecode, timecodePlaceholder]); const createRandomSegments = useCallback(async () => { if (!checkFileOpened() || !isDurationValid(duration)) return; diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index 45e16d77..dba9fa5f 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -85,6 +85,7 @@ export interface Thumbnail { } export type FormatTimecode = (a: { seconds: number, shorten?: boolean | undefined, fileNameFriendly?: boolean | undefined }) => string; +export type ParseTimecode = (val: string) => number | undefined; export type GetFrameCount = (sec: number) => number | undefined; diff --git a/src/renderer/src/util/duration.test.ts b/src/renderer/src/util/duration.test.ts index 501f03ce..56dd1890 100644 --- a/src/renderer/src/util/duration.test.ts +++ b/src/renderer/src/util/duration.test.ts @@ -41,6 +41,10 @@ it('should format and parse duration with correct rounding', () => { expect(formatDuration({ seconds: parseDuration('-1') })).toBe('-00:00:01.000'); expect(formatDuration({ seconds: parseDuration('01') })).toBe('00:00:01.000'); expect(formatDuration({ seconds: parseDuration('01:00:00.000') })).toBe('01:00:00.000'); + + expect(formatDuration({ seconds: parseDuration('00:00:00,00', 30), fps: 30 })).toBe('00:00:00.00'); + expect(formatDuration({ seconds: parseDuration('00:00:00,29', 30), fps: 30 })).toBe('00:00:00.29'); + expect(formatDuration({ seconds: parseDuration('01:00:01,01', 30), fps: 30 })).toBe('01:00:01.01'); }); // https://github.com/mifi/lossless-cut/issues/1603 diff --git a/src/renderer/src/util/duration.ts b/src/renderer/src/util/duration.ts index ff805280..0126fe12 100644 --- a/src/renderer/src/util/duration.ts +++ b/src/renderer/src/util/duration.ts @@ -39,22 +39,32 @@ export function formatDuration({ seconds: totalSecondsIn, fileNameFriendly, show return `${sign}${hoursPart}${minutesPadded}${delim}${secondsPadded}${fraction}`; } +// todo adapt also to frame counts and frame fractions? export const isExactDurationMatch = (str) => /^-?\d{2}:\d{2}:\d{2}.\d{3}$/.test(str); // See also parseYoutube -export function parseDuration(str) { +export function parseDuration(str: string, fps?: number) { // eslint-disable-next-line unicorn/better-regex const match = str.replaceAll(/\s/g, '').match(/^(-?)(?:(?:(\d{1,}):)?(\d{1,2}):)?(\d{1,2}(?:[.,]\d{1,3})?)$/); if (!match) return undefined; const [, sign, hourStr, minStr, secStrRaw] = match; - const secStr = secStrRaw.replace(',', '.'); const hour = hourStr != null ? parseInt(hourStr, 10) : 0; const min = minStr != null ? parseInt(minStr, 10) : 0; - const sec = parseFloat(secStr); - if (min > 59 || sec >= 60) return undefined; + const secWithFraction = secStrRaw!.replace(',', '.'); + + let sec: number; + if (fps == null) { + sec = parseFloat(secWithFraction); + } else { + const [secStr, framesStr] = secWithFraction.split('.'); + sec = parseInt(secStr!, 10) + parseInt(framesStr!, 10) / fps; + } + + if (min > 59) return undefined; + if (sec >= 60) return undefined; let time = (((hour * 60) + min) * 60 + sec);