1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

implement consistent duration format

#1960
This commit is contained in:
Mikael Finstad 2024-04-10 22:08:33 +02:00
parent 88fbcc0129
commit de9e927d26
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
8 changed files with 114 additions and 72 deletions

View File

@ -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<FormatTimecode>(({ 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<ParseTimecode>((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<FormatTimecode>(({ 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}
/>
</div>
@ -2731,6 +2750,7 @@ function App() {
setCustomTagsByFile={setCustomTagsByFile}
paramsByStreamId={paramsByStreamId}
updateStreamParams={updateStreamParams}
formatTimecode={formatTimecode}
/>
)}
</Sheet>

View File

@ -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 })}
/>
</form>
);
@ -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(({
<SetCutpointButton currentCutSeg={currentCutSeg} side="start" onClick={setCutStart} title={t('Start current segment at current time')} style={{ marginRight: 5 }} />
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} currentApparentCutSeg={currentApparentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentApparentCutSeg.start} setCutTime={setCutTime} isStart />}
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} currentApparentCutSeg={currentApparentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentApparentCutSeg.start} setCutTime={setCutTime} isStart formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
<IoMdKey
size={25}
@ -353,7 +354,7 @@ const BottomBar = memo(({
onClick={() => seekClosestKeyframe(1)}
/>
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} currentApparentCutSeg={currentApparentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentApparentCutSeg.end} setCutTime={setCutTime} />}
{!simpleMode && <CutTimeInput darkMode={darkMode} currentCutSeg={currentCutSeg} currentApparentCutSeg={currentApparentCutSeg} startTimeOffset={startTimeOffset} seekAbs={seekAbs} cutTime={currentApparentCutSeg.end} setCutTime={setCutTime} formatTimecode={formatTimecode} parseTimecode={parseTimecode} />}
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('End current segment at current time')} style={{ marginLeft: 5 }} />

View File

@ -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
</td>
<td style={{ maxWidth: '3em', overflow: 'hidden' }} title={stream.codec_name}>{stream.codec_name} {codecTag}</td>
<td>
{!Number.isNaN(duration) && `${formatDuration({ seconds: duration, shorten: true })}`}
{!Number.isNaN(duration) && `${formatTimecode({ seconds: duration, shorten: true })}`}
{stream.nb_frames != null ? <div>{stream.nb_frames}f</div> : null}
</td>
<td>{!Number.isNaN(bitrate) && (stream.codec_type === 'audio' ? `${Math.round(bitrate / 1000)} kbps` : prettyBytes(bitrate, { bits: true }))}</td>
@ -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}
/>
))}
</tbody>
@ -385,6 +386,7 @@ const StreamsSelector = memo(({
fileDuration={getFormatDuration(formatData)}
paramsByStreamId={paramsByStreamId}
updateStreamParams={updateStreamParams}
formatTimecode={formatTimecode}
/>
))}
</tbody>

View File

@ -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) {

View File

@ -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<boolean>, 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<boolean>, 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;

View File

@ -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;

View File

@ -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

View File

@ -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);