mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 10:22:31 +01:00
parent
88fbcc0129
commit
de9e927d26
@ -70,10 +70,10 @@ import {
|
|||||||
isStoreBuild, dragPreventer,
|
isStoreBuild, dragPreventer,
|
||||||
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
||||||
deleteFiles, isOutOfSpaceError, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
deleteFiles, isOutOfSpaceError, isExecaFailure, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
||||||
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities,
|
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, getFrameDuration,
|
||||||
} from './util';
|
} from './util';
|
||||||
import { toast, errorToast } from './swal';
|
import { toast, errorToast } from './swal';
|
||||||
import { formatDuration } from './util/duration';
|
import { formatDuration, parseDuration } from './util/duration';
|
||||||
import { adjustRate } from './util/rate-calculator';
|
import { adjustRate } from './util/rate-calculator';
|
||||||
import { askExtractFramesAsImages } from './dialogs/extractFrames';
|
import { askExtractFramesAsImages } from './dialogs/extractFrames';
|
||||||
import { askForHtml5ifySpeed } from './dialogs/html5ify';
|
import { askForHtml5ifySpeed } from './dialogs/html5ify';
|
||||||
@ -86,7 +86,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
|
|||||||
import BigWaveform from './components/BigWaveform';
|
import BigWaveform from './components/BigWaveform';
|
||||||
|
|
||||||
import isDev from './isDev';
|
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 { CaptureFormat, KeyboardAction, Html5ifyMode } from '../../../types';
|
||||||
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
|
import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../ffprobe';
|
||||||
|
|
||||||
@ -368,9 +368,46 @@ function App() {
|
|||||||
return false;
|
return false;
|
||||||
}, [isFileOpened]);
|
}, [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 {
|
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,
|
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(() => {
|
const segmentAtCursor = useMemo(() => {
|
||||||
@ -416,30 +453,6 @@ function App() {
|
|||||||
const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]);
|
const jumpTimelineStart = useCallback(() => userSeekAbs(0), [userSeekAbs]);
|
||||||
const jumpTimelineEnd = useCallback(() => userSeekAbs(durationSafe), [durationSafe, 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 { captureFrameFromTag, captureFrameFromFfmpeg, captureFramesRange } = useFrameCapture({ formatTimecode, treatOutputFileModifiedTimeAsStart });
|
||||||
|
|
||||||
// const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]);
|
// const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]);
|
||||||
@ -1703,14 +1716,16 @@ function App() {
|
|||||||
const goToTimecode = useCallback(async () => {
|
const goToTimecode = useCallback(async () => {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
const timeCode = await promptTimeOffset({
|
const timeCode = await promptTimeOffset({
|
||||||
initialValue: formatDuration({ seconds: commandedTimeRef.current }),
|
initialValue: formatTimecode({ seconds: commandedTimeRef.current }),
|
||||||
title: i18n.t('Seek to timecode'),
|
title: i18n.t('Seek to timecode'),
|
||||||
|
inputPlaceholder: timecodePlaceholder,
|
||||||
|
parseTimecode,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (timeCode === undefined) return;
|
if (timeCode === undefined) return;
|
||||||
|
|
||||||
userSeekAbs(timeCode);
|
userSeekAbs(timeCode);
|
||||||
}, [filePath, userSeekAbs]);
|
}, [filePath, formatTimecode, parseTimecode, timecodePlaceholder, userSeekAbs]);
|
||||||
|
|
||||||
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
|
const toggleStreamsSelector = useCallback(() => setStreamsSelectorShown((v) => !v), []);
|
||||||
|
|
||||||
@ -1776,15 +1791,17 @@ function App() {
|
|||||||
|
|
||||||
const askStartTimeOffset = useCallback(async () => {
|
const askStartTimeOffset = useCallback(async () => {
|
||||||
const newStartTimeOffset = await promptTimeOffset({
|
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'),
|
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)'),
|
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;
|
if (newStartTimeOffset === undefined) return;
|
||||||
|
|
||||||
setStartTimeOffset(newStartTimeOffset);
|
setStartTimeOffset(newStartTimeOffset);
|
||||||
}, [startTimeOffset]);
|
}, [formatTimecode, parseTimecode, startTimeOffset, timecodePlaceholder]);
|
||||||
|
|
||||||
const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []);
|
const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []);
|
||||||
|
|
||||||
@ -2701,6 +2718,8 @@ function App() {
|
|||||||
setDarkMode={setDarkMode}
|
setDarkMode={setDarkMode}
|
||||||
outputPlaybackRate={outputPlaybackRate}
|
outputPlaybackRate={outputPlaybackRate}
|
||||||
setOutputPlaybackRate={setOutputPlaybackRate}
|
setOutputPlaybackRate={setOutputPlaybackRate}
|
||||||
|
formatTimecode={formatTimecode}
|
||||||
|
parseTimecode={parseTimecode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -2731,6 +2750,7 @@ function App() {
|
|||||||
setCustomTagsByFile={setCustomTagsByFile}
|
setCustomTagsByFile={setCustomTagsByFile}
|
||||||
paramsByStreamId={paramsByStreamId}
|
paramsByStreamId={paramsByStreamId}
|
||||||
updateStreamParams={updateStreamParams}
|
updateStreamParams={updateStreamParams}
|
||||||
|
formatTimecode={formatTimecode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
@ -20,7 +20,7 @@ import { withBlur, mirrorTransform, checkAppPath } from './util';
|
|||||||
import { toast } from './swal';
|
import { toast } from './swal';
|
||||||
import { getSegColor as getSegColorRaw } from './util/colors';
|
import { getSegColor as getSegColorRaw } from './util/colors';
|
||||||
import { useSegColors } from './contexts';
|
import { useSegColors } from './contexts';
|
||||||
import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration';
|
import { isExactDurationMatch } from './util/duration';
|
||||||
import useUserSettings from './hooks/useUserSettings';
|
import useUserSettings from './hooks/useUserSettings';
|
||||||
import { askForPlaybackRate } from './dialogs';
|
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 { t } = useTranslation();
|
||||||
const { getSegColor } = useSegColors();
|
const { getSegColor } = useSegColors();
|
||||||
|
|
||||||
@ -102,19 +102,19 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Don't proceed if not a valid time value
|
// Don't proceed if not a valid time value
|
||||||
const timeWithOffset = parseDuration(cutTimeManual);
|
const timeWithOffset = parseTimecode(cutTimeManual);
|
||||||
if (timeWithOffset === undefined) return;
|
if (timeWithOffset === undefined) return;
|
||||||
|
|
||||||
trySetTime(timeWithOffset);
|
trySetTime(timeWithOffset);
|
||||||
}, [cutTimeManual, trySetTime]);
|
}, [cutTimeManual, parseTimecode, trySetTime]);
|
||||||
|
|
||||||
const parseAndSetCutTime = useCallback((text) => {
|
const parseAndSetCutTime = useCallback((text) => {
|
||||||
// Don't proceed if not a valid time value
|
// Don't proceed if not a valid time value
|
||||||
const timeWithOffset = parseDuration(text);
|
const timeWithOffset = parseTimecode(text);
|
||||||
if (timeWithOffset === undefined) return;
|
if (timeWithOffset === undefined) return;
|
||||||
|
|
||||||
trySetTime(timeWithOffset);
|
trySetTime(timeWithOffset);
|
||||||
}, [trySetTime]);
|
}, [parseTimecode, trySetTime]);
|
||||||
|
|
||||||
function handleCutTimeInput(text) {
|
function handleCutTimeInput(text) {
|
||||||
setCutTimeManual(text);
|
setCutTimeManual(text);
|
||||||
@ -160,7 +160,7 @@ const CutTimeInput = memo(({ darkMode, cutTime, setCutTime, startTimeOffset, see
|
|||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
value={isCutTimeManualSet()
|
value={isCutTimeManualSet()
|
||||||
? cutTimeManual
|
? cutTimeManual
|
||||||
: formatDuration({ seconds: cutTime + startTimeOffset })}
|
: formatTimecode({ seconds: cutTime + startTimeOffset })}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
@ -178,6 +178,7 @@ const BottomBar = memo(({
|
|||||||
darkMode, setDarkMode,
|
darkMode, setDarkMode,
|
||||||
toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails,
|
toggleShowThumbnails, toggleWaveformMode, waveformMode, showThumbnails,
|
||||||
outputPlaybackRate, setOutputPlaybackRate,
|
outputPlaybackRate, setOutputPlaybackRate,
|
||||||
|
formatTimecode, parseTimecode,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getSegColor } = useSegColors();
|
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 }} />
|
<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
|
<IoMdKey
|
||||||
size={25}
|
size={25}
|
||||||
@ -353,7 +354,7 @@ const BottomBar = memo(({
|
|||||||
onClick={() => seekClosestKeyframe(1)}
|
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 }} />
|
<SetCutpointButton currentCutSeg={currentCutSeg} side="end" onClick={setCutEnd} title={t('End current segment at current time')} style={{ marginLeft: 5 }} />
|
||||||
|
|
||||||
|
@ -10,7 +10,6 @@ import prettyBytes from 'pretty-bytes';
|
|||||||
import AutoExportToggler from './components/AutoExportToggler';
|
import AutoExportToggler from './components/AutoExportToggler';
|
||||||
import Select from './components/Select';
|
import Select from './components/Select';
|
||||||
import { showJson5Dialog } from './dialogs';
|
import { showJson5Dialog } from './dialogs';
|
||||||
import { formatDuration } from './util/duration';
|
|
||||||
import { getStreamFps } from './ffmpeg';
|
import { getStreamFps } from './ffmpeg';
|
||||||
import { deleteDispositionValue } from './util';
|
import { deleteDispositionValue } from './util';
|
||||||
import { getActiveDisposition, attachedPicDisposition } from './util/streams';
|
import { getActiveDisposition, attachedPicDisposition } from './util/streams';
|
||||||
@ -131,7 +130,7 @@ function onInfoClick(json, title) {
|
|||||||
showJson5Dialog({ title, json });
|
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 { t } = useTranslation();
|
||||||
|
|
||||||
const effectiveDisposition = useMemo(() => getStreamEffectiveDisposition(paramsByStreamId, filePath, stream), [filePath, paramsByStreamId, stream]);
|
const effectiveDisposition = useMemo(() => getStreamEffectiveDisposition(paramsByStreamId, filePath, stream), [filePath, paramsByStreamId, stream]);
|
||||||
@ -192,7 +191,7 @@ const Stream = memo(({ filePath, stream, onToggle, batchSetCopyStreamIds, copySt
|
|||||||
</td>
|
</td>
|
||||||
<td style={{ maxWidth: '3em', overflow: 'hidden' }} title={stream.codec_name}>{stream.codec_name} {codecTag}</td>
|
<td style={{ maxWidth: '3em', overflow: 'hidden' }} title={stream.codec_name}>{stream.codec_name} {codecTag}</td>
|
||||||
<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}
|
{stream.nb_frames != null ? <div>{stream.nb_frames}f</div> : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{!Number.isNaN(bitrate) && (stream.codec_type === 'audio' ? `${Math.round(bitrate / 1000)} kbps` : prettyBytes(bitrate, { bits: true }))}</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,
|
setCopyStreamIdsForPath, onExtractStreamPress, onExtractAllStreamsPress, allFilesMeta, externalFilesMeta, setExternalFilesMeta,
|
||||||
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
|
showAddStreamSourceDialog, shortestFlag, setShortestFlag, nonCopiedExtraStreams,
|
||||||
customTagsByFile, setCustomTagsByFile, paramsByStreamId, updateStreamParams,
|
customTagsByFile, setCustomTagsByFile, paramsByStreamId, updateStreamParams,
|
||||||
|
formatTimecode,
|
||||||
}) => {
|
}) => {
|
||||||
const [editingFile, setEditingFile] = useState();
|
const [editingFile, setEditingFile] = useState();
|
||||||
const [editingStream, setEditingStream] = useState();
|
const [editingStream, setEditingStream] = useState();
|
||||||
@ -360,6 +360,7 @@ const StreamsSelector = memo(({
|
|||||||
onExtractStreamPress={() => onExtractStreamPress(stream.index)}
|
onExtractStreamPress={() => onExtractStreamPress(stream.index)}
|
||||||
paramsByStreamId={paramsByStreamId}
|
paramsByStreamId={paramsByStreamId}
|
||||||
updateStreamParams={updateStreamParams}
|
updateStreamParams={updateStreamParams}
|
||||||
|
formatTimecode={formatTimecode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -385,6 +386,7 @@ const StreamsSelector = memo(({
|
|||||||
fileDuration={getFormatDuration(formatData)}
|
fileDuration={getFormatDuration(formatData)}
|
||||||
paramsByStreamId={paramsByStreamId}
|
paramsByStreamId={paramsByStreamId}
|
||||||
updateStreamParams={updateStreamParams}
|
updateStreamParams={updateStreamParams}
|
||||||
|
formatTimecode={formatTimecode}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
@ -8,18 +8,18 @@ import { tomorrow as syntaxStyle } from 'react-syntax-highlighter/dist/esm/style
|
|||||||
import JSON5 from 'json5';
|
import JSON5 from 'json5';
|
||||||
import { SweetAlertOptions } from 'sweetalert2';
|
import { SweetAlertOptions } from 'sweetalert2';
|
||||||
|
|
||||||
import { parseDuration, formatDuration } from '../util/duration';
|
import { formatDuration } from '../util/duration';
|
||||||
import Swal, { swalToastOptions, toast } from '../swal';
|
import Swal, { swalToastOptions, toast } from '../swal';
|
||||||
import { parseYouTube } from '../edlFormats';
|
import { parseYouTube } from '../edlFormats';
|
||||||
import CopyClipboardButton from '../components/CopyClipboardButton';
|
import CopyClipboardButton from '../components/CopyClipboardButton';
|
||||||
import { isWindows, showItemInFolder } from '../util';
|
import { isWindows, showItemInFolder } from '../util';
|
||||||
import { SegmentBase } from '../types';
|
import { ParseTimecode, SegmentBase } from '../types';
|
||||||
|
|
||||||
const { dialog } = window.require('@electron/remote');
|
const { dialog } = window.require('@electron/remote');
|
||||||
|
|
||||||
const ReactSwal = withReactContent(Swal);
|
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
|
// @ts-expect-error todo
|
||||||
const { value } = await Swal.fire({
|
const { value } = await Swal.fire({
|
||||||
title,
|
title,
|
||||||
@ -27,16 +27,16 @@ export async function promptTimeOffset({ initialValue, title, text }: { initialV
|
|||||||
input: 'text',
|
input: 'text',
|
||||||
inputValue: initialValue || '',
|
inputValue: initialValue || '',
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
inputPlaceholder: '00:00:00.000',
|
inputPlaceholder,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (value === undefined) {
|
if (value === undefined) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duration = parseDuration(value);
|
const duration = parseTimecode(value);
|
||||||
// Invalid, try again
|
// 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;
|
return duration;
|
||||||
}
|
}
|
||||||
@ -200,25 +200,27 @@ export async function createNumSegments(fileDuration) {
|
|||||||
|
|
||||||
const exampleDuration = '00:00:05.123';
|
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({
|
const { value } = await Swal.fire({
|
||||||
input: 'text',
|
input: 'text',
|
||||||
showCancelButton: true,
|
showCancelButton: true,
|
||||||
inputValue: '00:00:00.000',
|
inputValue: inputPlaceholder,
|
||||||
text: i18n.t('Divide timeline into a number of segments with the specified length'),
|
text: i18n.t('Divide timeline into a number of segments with the specified length'),
|
||||||
inputValidator: (v) => {
|
inputValidator: (v) => {
|
||||||
const duration = parseDuration(v);
|
const duration = parseTimecode(v);
|
||||||
if (duration != null) {
|
if (duration != null) {
|
||||||
const numSegments = Math.ceil(fileDuration / duration);
|
const numSegments = Math.ceil(fileDuration / duration);
|
||||||
if (duration > 0 && duration < fileDuration && numSegments <= maxSegments) return null;
|
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;
|
if (value == null) return undefined;
|
||||||
|
|
||||||
return parseDuration(value);
|
return parseTimecode(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/mifi/lossless-cut/issues/1153
|
// https://github.com/mifi/lossless-cut/issues/1153
|
||||||
@ -273,15 +275,15 @@ async function askForSegmentsStartOrEnd(text) {
|
|||||||
return value === 'both' ? ['start', 'end'] : [value];
|
return value === 'both' ? ['start', 'end'] : [value];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function askForShiftSegments() {
|
export async function askForShiftSegments({ inputPlaceholder, parseTimecode }: { inputPlaceholder: string, parseTimecode: ParseTimecode }) {
|
||||||
function parseValue(value) {
|
function parseValue(value: string) {
|
||||||
let parseableValue = value;
|
let parseableValue = value;
|
||||||
let sign = 1;
|
let sign = 1;
|
||||||
if (parseableValue[0] === '-') {
|
if (parseableValue[0] === '-') {
|
||||||
parseableValue = parseableValue.slice(1);
|
parseableValue = parseableValue.slice(1);
|
||||||
sign = -1;
|
sign = -1;
|
||||||
}
|
}
|
||||||
const duration = parseDuration(parseableValue);
|
const duration = parseTimecode(parseableValue);
|
||||||
if (duration != null && duration > 0) {
|
if (duration != null && duration > 0) {
|
||||||
return duration * sign;
|
return duration * sign;
|
||||||
}
|
}
|
||||||
@ -291,7 +293,7 @@ export async function askForShiftSegments() {
|
|||||||
const { value } = await Swal.fire({
|
const { value } = await Swal.fire({
|
||||||
input: 'text',
|
input: 'text',
|
||||||
showCancelButton: true,
|
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.'),
|
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) => {
|
inputValidator: (v) => {
|
||||||
const parsed = parseValue(v);
|
const parsed = parseValue(v);
|
||||||
@ -417,8 +419,10 @@ export async function showCleanupFilesDialog(cleanupChoicesIn) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createFixedDurationSegments(fileDuration) {
|
export async function createFixedDurationSegments({ fileDuration, inputPlaceholder, parseTimecode }: {
|
||||||
const segmentDuration = await askForSegmentDuration(fileDuration);
|
fileDuration: number, inputPlaceholder: string, parseTimecode: ParseTimecode,
|
||||||
|
}) {
|
||||||
|
const segmentDuration = await askForSegmentDuration({ fileDuration, inputPlaceholder, parseTimecode });
|
||||||
if (segmentDuration == null) return undefined;
|
if (segmentDuration == null) return undefined;
|
||||||
const edl: SegmentBase[] = [];
|
const edl: SegmentBase[] = [];
|
||||||
for (let start = 0; start < fileDuration; start += segmentDuration) {
|
for (let start = 0; start < fileDuration; start += segmentDuration) {
|
||||||
|
@ -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 { 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 * as ffmpegParameters from '../ffmpeg-parameters';
|
||||||
import { maxSegmentsAllowed } from '../util/constants';
|
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');
|
const { ffmpeg: { blackDetect, silenceDetect } } = window.require('@electron/remote').require('./index.js');
|
||||||
|
|
||||||
|
|
||||||
function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoStream, duration, getRelevantTime, maxLabelLength, checkFileOpened, invertCutSegments, segmentsToChaptersOnly }: {
|
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,
|
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
|
// Segment related state
|
||||||
const segCounterRef = useRef(0);
|
const segCounterRef = useRef(0);
|
||||||
@ -259,7 +259,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
|
|||||||
}, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]);
|
}, [apparentCutSegments, createInitialCutSegments, duration, isSegmentSelected, setCutSegments]);
|
||||||
|
|
||||||
const shiftAllSegmentTimes = useCallback(async () => {
|
const shiftAllSegmentTimes = useCallback(async () => {
|
||||||
const shift = await askForShiftSegments();
|
const shift = await askForShiftSegments({ inputPlaceholder: timecodePlaceholder, parseTimecode });
|
||||||
if (shift == null) return;
|
if (shift == null) return;
|
||||||
|
|
||||||
const { shiftAmount, shiftKeys } = shift;
|
const { shiftAmount, shiftKeys } = shift;
|
||||||
@ -270,7 +270,7 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
|
|||||||
});
|
});
|
||||||
return newSegment;
|
return newSegment;
|
||||||
});
|
});
|
||||||
}, [modifySelectedSegmentTimes]);
|
}, [modifySelectedSegmentTimes, parseTimecode, timecodePlaceholder]);
|
||||||
|
|
||||||
const alignSegmentTimesToKeyframes = useCallback(async () => {
|
const alignSegmentTimesToKeyframes = useCallback(async () => {
|
||||||
if (!videoStream || workingRef.current) return;
|
if (!videoStream || workingRef.current) return;
|
||||||
@ -444,9 +444,9 @@ function useSegments({ filePath, workingRef, setWorking, setCutProgress, videoSt
|
|||||||
|
|
||||||
const createFixedDurationSegments = useCallback(async () => {
|
const createFixedDurationSegments = useCallback(async () => {
|
||||||
if (!checkFileOpened() || !isDurationValid(duration)) return;
|
if (!checkFileOpened() || !isDurationValid(duration)) return;
|
||||||
const segments = await createFixedDurationSegmentsDialog(duration);
|
const segments = await createFixedDurationSegmentsDialog({ fileDuration: duration, inputPlaceholder: timecodePlaceholder, parseTimecode });
|
||||||
if (segments) loadCutSegments(segments);
|
if (segments) loadCutSegments(segments);
|
||||||
}, [checkFileOpened, duration, loadCutSegments]);
|
}, [checkFileOpened, duration, loadCutSegments, parseTimecode, timecodePlaceholder]);
|
||||||
|
|
||||||
const createRandomSegments = useCallback(async () => {
|
const createRandomSegments = useCallback(async () => {
|
||||||
if (!checkFileOpened() || !isDurationValid(duration)) return;
|
if (!checkFileOpened() || !isDurationValid(duration)) return;
|
||||||
|
@ -85,6 +85,7 @@ export interface Thumbnail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FormatTimecode = (a: { seconds: number, shorten?: boolean | undefined, fileNameFriendly?: boolean | undefined }) => string;
|
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;
|
export type GetFrameCount = (sec: number) => number | undefined;
|
||||||
|
|
||||||
|
@ -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('-1') })).toBe('-00:00:01.000');
|
||||||
expect(formatDuration({ seconds: parseDuration('01') })).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('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
|
// https://github.com/mifi/lossless-cut/issues/1603
|
||||||
|
@ -39,22 +39,32 @@ export function formatDuration({ seconds: totalSecondsIn, fileNameFriendly, show
|
|||||||
return `${sign}${hoursPart}${minutesPadded}${delim}${secondsPadded}${fraction}`;
|
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);
|
export const isExactDurationMatch = (str) => /^-?\d{2}:\d{2}:\d{2}.\d{3}$/.test(str);
|
||||||
|
|
||||||
// See also parseYoutube
|
// See also parseYoutube
|
||||||
export function parseDuration(str) {
|
export function parseDuration(str: string, fps?: number) {
|
||||||
// eslint-disable-next-line unicorn/better-regex
|
// 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})?)$/);
|
const match = str.replaceAll(/\s/g, '').match(/^(-?)(?:(?:(\d{1,}):)?(\d{1,2}):)?(\d{1,2}(?:[.,]\d{1,3})?)$/);
|
||||||
|
|
||||||
if (!match) return undefined;
|
if (!match) return undefined;
|
||||||
|
|
||||||
const [, sign, hourStr, minStr, secStrRaw] = match;
|
const [, sign, hourStr, minStr, secStrRaw] = match;
|
||||||
const secStr = secStrRaw.replace(',', '.');
|
|
||||||
const hour = hourStr != null ? parseInt(hourStr, 10) : 0;
|
const hour = hourStr != null ? parseInt(hourStr, 10) : 0;
|
||||||
const min = minStr != null ? parseInt(minStr, 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);
|
let time = (((hour * 60) + min) * 60 + sec);
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user