kopia lustrzana https://github.com/mifi/lossless-cut
rodzic
37af026932
commit
9509680c03
|
@ -47,6 +47,7 @@
|
|||
"@radix-ui/react-switch": "^1.0.1",
|
||||
"@tsconfig/strictest": "^2.0.2",
|
||||
"@tsconfig/vite-react": "^3.0.0",
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/sortablejs": "^1.15.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
||||
"@typescript-eslint/parser": "^6.17.0",
|
||||
|
|
|
@ -131,6 +131,7 @@ const defaults = {
|
|||
darkMode: true,
|
||||
preferStrongColors: false,
|
||||
outputFileNameMinZeroPadding: 1,
|
||||
cutFromAdjustmentFrames: 0,
|
||||
};
|
||||
|
||||
// For portable app: https://github.com/mifi/lossless-cut/issues/645
|
||||
|
|
26
src/App.tsx
26
src/App.tsx
|
@ -85,7 +85,7 @@ import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './uti
|
|||
import BigWaveform from './components/BigWaveform';
|
||||
|
||||
import isDev from './isDev';
|
||||
import { EdlFileType, FfmpegCommandLog, Html5ifyMode, TunerType } from './types';
|
||||
import { EdlFileType, FfmpegCommandLog, FfprobeChapter, FfprobeFormat, FfprobeStream, Html5ifyMode, Thumbnail, TunerType } from './types';
|
||||
|
||||
const electron = window.require('electron');
|
||||
const { exists } = window.require('fs-extra');
|
||||
|
@ -125,12 +125,12 @@ function App() {
|
|||
const [customTagsByFile, setCustomTagsByFile] = useState({});
|
||||
const [paramsByStreamId, setParamsByStreamId] = useState(new Map());
|
||||
const [detectedFps, setDetectedFps] = useState<number>();
|
||||
const [mainFileMeta, setMainFileMeta] = useState<{ streams: any[], formatData: any, chapters?: any }>({ streams: [], formatData: {} });
|
||||
const [mainFileMeta, setMainFileMeta] = useState<{ streams: FfprobeStream[], formatData: FfprobeFormat, chapters?: FfprobeChapter[] }>({ streams: [], formatData: {} });
|
||||
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState<Record<string, Record<string, boolean>>>({});
|
||||
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
|
||||
const [concatDialogVisible, setConcatDialogVisible] = useState(false);
|
||||
const [zoomUnrounded, setZoom] = useState(1);
|
||||
const [thumbnails, setThumbnails] = useState<{ from: number, url: string }[]>([]);
|
||||
const [thumbnails, setThumbnails] = useState<Thumbnail[]>([]);
|
||||
const [shortestFlag, setShortestFlag] = useState(false);
|
||||
const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0);
|
||||
const [subtitlesByStreamId, setSubtitlesByStreamId] = useState<Record<string, { url: string, lang?: string }>>({});
|
||||
|
@ -191,7 +191,7 @@ function App() {
|
|||
const allUserSettings = useUserSettingsRoot();
|
||||
|
||||
const {
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding,
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
|
||||
} = allUserSettings;
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -345,7 +345,7 @@ function App() {
|
|||
|
||||
const activeVideoStream = useMemo(() => (activeVideoStreamIndex != null ? videoStreams.find((stream) => stream.index === activeVideoStreamIndex) : undefined) ?? mainVideoStream, [activeVideoStreamIndex, mainVideoStream, videoStreams]);
|
||||
const activeAudioStream = useMemo(() => (activeAudioStreamIndex != null ? audioStreams.find((stream) => stream.index === activeAudioStreamIndex) : undefined) ?? mainAudioStream, [activeAudioStreamIndex, audioStreams, mainAudioStream]);
|
||||
const activeSubtitle = useMemo(() => activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined, [activeSubtitleStreamIndex, subtitlesByStreamId]);
|
||||
const activeSubtitle = useMemo(() => (activeSubtitleStreamIndex != null ? subtitlesByStreamId[activeSubtitleStreamIndex] : undefined), [activeSubtitleStreamIndex, subtitlesByStreamId]);
|
||||
|
||||
// 360 means we don't modify rotation gtrgt
|
||||
const isRotationSet = rotation !== 360;
|
||||
|
@ -673,7 +673,7 @@ function App() {
|
|||
const toggleStripAudio = useCallback(() => toggleStripStream((stream) => stream.codec_type === 'audio'), [toggleStripStream]);
|
||||
const toggleStripThumbnail = useCallback(() => toggleStripStream(isStreamThumbnail), [toggleStripStream]);
|
||||
|
||||
const thumnailsRef = useRef<{ from: number, url: string }[]>([]);
|
||||
const thumnailsRef = useRef<Thumbnail[]>([]);
|
||||
const thumnailsRenderingPromiseRef = useRef<Promise<void>>();
|
||||
|
||||
function addThumbnail(thumbnail) {
|
||||
|
@ -798,7 +798,7 @@ function App() {
|
|||
|
||||
const {
|
||||
concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration,
|
||||
} = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate });
|
||||
} = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames });
|
||||
|
||||
const html5ifyAndLoad = useCallback(async (cod, fp, speed, hv, ha) => {
|
||||
const usesDummyVideo = speed === 'fastest';
|
||||
|
@ -867,6 +867,7 @@ function App() {
|
|||
setTotalProgress();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null as any as undefined, showConfirmButton: true });
|
||||
} catch (err) {
|
||||
errorToast(i18n.t('Failed to batch convert to supported format'));
|
||||
|
@ -1078,10 +1079,10 @@ function App() {
|
|||
// assume execa killed (aborted by user)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if ('stdout' in err) console.error('stdout:', err.stdout);
|
||||
if ('stderr' in err) console.error('stderr:', err.stderr);
|
||||
|
||||
|
||||
if (isExecaFailure(err)) {
|
||||
if (isOutOfSpaceError(err)) {
|
||||
showDiskFull();
|
||||
|
@ -1296,10 +1297,10 @@ function App() {
|
|||
// assume execa killed (aborted by user)
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if ('stdout' in err) console.error('stdout:', err.stdout);
|
||||
if ('stderr' in err) console.error('stderr:', err.stderr);
|
||||
|
||||
|
||||
if (isExecaFailure(err)) {
|
||||
if (isOutOfSpaceError(err)) {
|
||||
showDiskFull();
|
||||
|
@ -2305,6 +2306,8 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
// todo
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actionsWithArgs: Record<string, (...args: any[]) => void> = {
|
||||
openFiles: (filePaths: string[]) => { userOpenFiles(filePaths.map(resolvePathIfNeeded)); },
|
||||
// todo separate actions per type and move them into mainActions? https://github.com/mifi/lossless-cut/issues/254#issuecomment-932649424
|
||||
|
@ -2320,6 +2323,7 @@ function App() {
|
|||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const actionsWithCatch: Readonly<[string, (event: unknown, ...a: any) => Promise<void>]>[] = [
|
||||
// actions with arguments:
|
||||
...Object.entries(actionsWithArgs).map(([key, fn]) => [
|
||||
|
|
|
@ -41,7 +41,7 @@ const ExportConfirm = memo(({
|
|||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, movFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, preserveMetadataOnMerge, togglePreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental } = useUserSettings();
|
||||
const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, movFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, preserveMetadataOnMerge, togglePreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames } = useUserSettings();
|
||||
|
||||
const isMov = ffmpegIsMov(outFormat);
|
||||
const isIpod = outFormat === 'ipod';
|
||||
|
@ -109,6 +109,10 @@ const ExportConfirm = memo(({
|
|||
toast.fire({ icon: 'info', timer: 10000, text: `${avoidNegativeTs}: ${texts[avoidNegativeTs]}` });
|
||||
}, [avoidNegativeTs]);
|
||||
|
||||
const onCutFromAdjustmentFramesHelpPress = useCallback(() => {
|
||||
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('Shift all segment start times forward by a number of frames before cutting in order to avoid starting at the wrong keyframe.') });
|
||||
}, []);
|
||||
|
||||
const onFfmpegExperimentalHelpPress = useCallback(() => {
|
||||
toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') });
|
||||
}, [t]);
|
||||
|
@ -318,6 +322,22 @@ const ExportConfirm = memo(({
|
|||
</>
|
||||
)}
|
||||
|
||||
{areWeCutting && (
|
||||
<tr>
|
||||
<td>
|
||||
{t('Shift all start times')}
|
||||
</td>
|
||||
<td>
|
||||
<Select value={cutFromAdjustmentFrames} onChange={(e) => setCutFromAdjustmentFrames(Number(e.target.value))} style={{ height: 20, marginLeft: 5 }}>
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => <option key={v} value={v}>{t('+{{numFrames}} frames', { numFrames: v, count: v })}</option>)}
|
||||
</Select>
|
||||
</td>
|
||||
<td>
|
||||
<HelpIcon onClick={onCutFromAdjustmentFramesHelpPress} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{isMov && (
|
||||
<>
|
||||
<tr>
|
||||
|
|
|
@ -48,10 +48,21 @@ function getIntervalAroundTime(time, window) {
|
|||
};
|
||||
}
|
||||
|
||||
interface Keyframe {
|
||||
time: number,
|
||||
createdAt: Date,
|
||||
}
|
||||
|
||||
interface Frame extends Keyframe {
|
||||
keyframe: boolean
|
||||
}
|
||||
|
||||
export async function readFrames({ filePath, from, to, streamIndex }) {
|
||||
const intervalsArgs = from != null && to != null ? ['-read_intervals', `${from}%${to}`] : [];
|
||||
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', streamIndex, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
|
||||
const packetsFiltered = JSON.parse(stdout).packets
|
||||
// todo types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const packetsFiltered: Frame[] = (JSON.parse(stdout).packets as any[])
|
||||
.map(p => ({
|
||||
keyframe: p.flags[0] === 'K',
|
||||
time: parseFloat(p.pts_time),
|
||||
|
@ -73,12 +84,14 @@ export async function readKeyframesAroundTime({ filePath, streamIndex, aroundTim
|
|||
return frames.filter((frame) => frame.keyframe);
|
||||
}
|
||||
|
||||
export const findKeyframeAtExactTime = (keyframes, time) => keyframes.find((keyframe) => Math.abs(keyframe.time - time) < 0.000001);
|
||||
export const findNextKeyframe = (keyframes, time) => keyframes.find((keyframe) => keyframe.time >= time); // (assume they are already sorted)
|
||||
const findPreviousKeyframe = (keyframes, time) => keyframes.findLast((keyframe) => keyframe.time <= time);
|
||||
const findNearestKeyframe = (keyframes, time) => minBy(keyframes, (keyframe) => Math.abs(keyframe.time - time));
|
||||
export const findKeyframeAtExactTime = (keyframes: Keyframe[], time: number) => keyframes.find((keyframe) => Math.abs(keyframe.time - time) < 0.000001);
|
||||
export const findNextKeyframe = (keyframes: Keyframe[], time: number) => keyframes.find((keyframe) => keyframe.time >= time); // (assume they are already sorted)
|
||||
const findPreviousKeyframe = (keyframes: Keyframe[], time: number) => keyframes.findLast((keyframe) => keyframe.time <= time);
|
||||
const findNearestKeyframe = (keyframes: Keyframe[], time: number) => minBy(keyframes, (keyframe) => Math.abs(keyframe.time - time));
|
||||
|
||||
function findKeyframe(keyframes, time, mode) {
|
||||
export type FindKeyframeMode = 'nearest' | 'before' | 'after';
|
||||
|
||||
function findKeyframe(keyframes: Keyframe[], time: number, mode: FindKeyframeMode) {
|
||||
switch (mode) {
|
||||
case 'nearest': return findNearestKeyframe(keyframes, time);
|
||||
case 'before': return findPreviousKeyframe(keyframes, time);
|
||||
|
@ -87,7 +100,7 @@ function findKeyframe(keyframes, time, mode) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function findKeyframeNearTime({ filePath, streamIndex, time, mode }) {
|
||||
export async function findKeyframeNearTime({ filePath, streamIndex, time, mode }: { filePath: string, streamIndex: number, time: number, mode: FindKeyframeMode }) {
|
||||
let keyframes = await readKeyframesAroundTime({ filePath, streamIndex, aroundTime: time, window: 10 });
|
||||
let nearByKeyframe = findKeyframe(keyframes, time, mode);
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import flatMap from 'lodash/flatMap';
|
|||
import sum from 'lodash/sum';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry } from '../util';
|
||||
import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath, unlinkWithRetry, getFrameDuration } from '../util';
|
||||
import { isCuttingStart, isCuttingEnd, runFfmpegWithProgress, getFfCommandLine, getDuration, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, logStdoutStderr, runFfmpegConcat } from '../ffmpeg';
|
||||
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
|
||||
import { getSmartCutParams } from '../smartcut';
|
||||
|
@ -56,7 +56,7 @@ async function tryDeleteFiles(paths) {
|
|||
return pMap(paths, (path) => unlinkWithRetry(path).catch((err) => console.error('Failed to delete', path, err)), { concurrency: 5 });
|
||||
}
|
||||
|
||||
function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate }) {
|
||||
function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames }) {
|
||||
const shouldSkipExistingFile = useCallback(async (path) => {
|
||||
const skip = !enableOverwriteOutput && await pathExists(path);
|
||||
if (skip) console.log('Not overwriting existing file', path);
|
||||
|
@ -119,7 +119,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
manuallyCopyDisposition: true,
|
||||
});
|
||||
|
||||
// Keep this similar to cutSingle()
|
||||
// Keep this similar to losslessCutSingle()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
|
@ -172,20 +172,24 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
}
|
||||
}, [shouldSkipExistingFile, treatOutputFileModifiedTimeAsStart]);
|
||||
|
||||
const cutSingle = useCallback(async ({
|
||||
const losslessCutSingle = useCallback(async ({
|
||||
keyframeCut: ssBeforeInput, avoidNegativeTs, copyFileStreams, cutFrom, cutTo, chaptersPath, onProgress, outPath,
|
||||
videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, videoTimebase,
|
||||
videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, videoTimebase, detectedFps,
|
||||
}) => {
|
||||
if (await shouldSkipExistingFile(outPath)) return;
|
||||
|
||||
const cuttingStart = isCuttingStart(cutFrom);
|
||||
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
|
||||
console.log('Cutting from', cuttingStart ? cutFrom : 'start', 'to', cuttingEnd ? cutTo : 'end');
|
||||
const frameDuration = getFrameDuration(detectedFps);
|
||||
|
||||
const cutDuration = cutTo - cutFrom;
|
||||
const cuttingStart = isCuttingStart(cutFrom);
|
||||
const cutFromWithAdjustment = cutFrom + cutFromAdjustmentFrames * frameDuration;
|
||||
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
|
||||
console.log('Cutting from', cuttingStart ? `${cutFrom} (${cutFromWithAdjustment} adjusted ${cutFromAdjustmentFrames} frames)` : 'start', 'to', cuttingEnd ? cutTo : 'end');
|
||||
|
||||
let cutDuration = cutTo - cutFromWithAdjustment;
|
||||
if (detectedFps != null) cutDuration = Math.max(cutDuration, frameDuration); // ensure at least one frame duration
|
||||
|
||||
// Don't cut if no need: https://github.com/mifi/lossless-cut/issues/50
|
||||
const cutFromArgs = cuttingStart ? ['-ss', cutFrom.toFixed(5)] : [];
|
||||
const cutFromArgs = cuttingStart ? ['-ss', cutFromWithAdjustment.toFixed(5)] : [];
|
||||
const cutToArgs = cuttingEnd ? ['-t', cutDuration.toFixed(5)] : [];
|
||||
|
||||
const copyFileStreamsFiltered = copyFileStreams.filter(({ streamIds }) => streamIds.length > 0);
|
||||
|
@ -321,7 +325,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
logStdoutStderr(result);
|
||||
|
||||
await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart });
|
||||
}, [filePath, getOutputPlaybackRateArgs, outputPlaybackRate, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
|
||||
}, [cutFromAdjustmentFrames, filePath, getOutputPlaybackRateArgs, outputPlaybackRate, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
|
||||
|
||||
const cutMultiple = useCallback(async ({
|
||||
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
||||
|
@ -340,7 +344,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
|
||||
const chaptersPath = await writeChaptersFfmetadata(outputDir, chapters);
|
||||
|
||||
// This function will either call cutSingle (if no smart cut enabled)
|
||||
// This function will either call losslessCutSingle (if no smart cut enabled)
|
||||
// or if enabled, will first cut&encode the part before the next keyframe, trying to match the input file's codec params
|
||||
// then it will cut the part *from* the keyframe to "end", and concat them together and return the concated file
|
||||
// so that for the calling code it looks as if it's just a normal segment
|
||||
|
@ -354,9 +358,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
}
|
||||
|
||||
if (!needSmartCut) {
|
||||
// old fashioned way
|
||||
const outPath = await makeSegmentOutPath();
|
||||
await cutSingle({
|
||||
await losslessCutSingle({
|
||||
cutFrom: desiredCutFrom, cutTo, chaptersPath, outPath, copyFileStreams, keyframeCut, avoidNegativeTs, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, onProgress: (progress) => onSingleProgress(i, progress),
|
||||
});
|
||||
return outPath;
|
||||
|
@ -367,7 +370,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
const streamsToCopyFromMainFile = copyFileStreams.find(({ path }) => path === filePath).streamIds
|
||||
.map((streamId) => streams.find((stream) => stream.index === streamId));
|
||||
|
||||
const { cutFrom: encodeCutTo, segmentNeedsSmartCut, videoCodec, videoBitrate, videoStreamIndex, videoTimebase } = await getSmartCutParams({ path: filePath, videoDuration, desiredCutFrom, streams: streamsToCopyFromMainFile });
|
||||
const { losslessCutFrom, segmentNeedsSmartCut, videoCodec, videoBitrate, videoStreamIndex, videoTimebase } = await getSmartCutParams({ path: filePath, videoDuration, desiredCutFrom, streams: streamsToCopyFromMainFile });
|
||||
|
||||
if (segmentNeedsSmartCut && !detectedFps) throw new Error('Smart cut is not possible when FPS is unknown');
|
||||
|
||||
|
@ -390,42 +393,49 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
|
||||
// If we are cutting within two keyframes, just encode the whole part and return that
|
||||
// See https://github.com/mifi/lossless-cut/pull/1267#issuecomment-1236381740
|
||||
if (segmentNeedsSmartCut && encodeCutTo > cutTo) {
|
||||
if (segmentNeedsSmartCut && losslessCutFrom > cutTo) {
|
||||
const outPath = await makeSegmentOutPath();
|
||||
console.log('Segment is between two keyframes, cutting/encoding the whole segment', { desiredCutFrom, losslessCutFrom, cutTo });
|
||||
await cutEncodeSmartPartWrapper({ cutFrom: desiredCutFrom, cutTo, outPath });
|
||||
return outPath;
|
||||
}
|
||||
|
||||
const ext = getOutFileExtension({ isCustomFormatSelected: true, outFormat, filePath });
|
||||
|
||||
const smartCutMainPartOutPath = segmentNeedsSmartCut
|
||||
const losslessPartOutPath = segmentNeedsSmartCut
|
||||
? getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-copy-${i}${ext}` })
|
||||
: await makeSegmentOutPath();
|
||||
|
||||
const smartCutEncodedPartOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` });
|
||||
|
||||
const smartCutSegmentsToConcat = [smartCutEncodedPartOutPath, smartCutMainPartOutPath];
|
||||
if (segmentNeedsSmartCut) {
|
||||
console.log('Cutting/encoding lossless part', { from: losslessCutFrom, to: cutTo });
|
||||
}
|
||||
|
||||
// for smart cut we need to use keyframe cut here, and no avoid_negative_ts
|
||||
await cutSingle({
|
||||
cutFrom: encodeCutTo, cutTo, chaptersPath, outPath: smartCutMainPartOutPath, copyFileStreams: copyFileStreamsFiltered, keyframeCut: true, avoidNegativeTs: false, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, videoTimebase, onProgress: onCutProgress,
|
||||
await losslessCutSingle({
|
||||
cutFrom: losslessCutFrom, cutTo, chaptersPath, outPath: losslessPartOutPath, copyFileStreams: copyFileStreamsFiltered, keyframeCut: true, avoidNegativeTs: false, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, paramsByStreamId, videoTimebase, onProgress: onCutProgress,
|
||||
});
|
||||
|
||||
// OK, just return the single cut file (we may need smart cut in other segments though)
|
||||
if (!segmentNeedsSmartCut) return smartCutMainPartOutPath;
|
||||
if (!segmentNeedsSmartCut) return losslessPartOutPath;
|
||||
|
||||
const smartCutEncodedPartOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` });
|
||||
const smartCutSegmentsToConcat = [smartCutEncodedPartOutPath, losslessPartOutPath];
|
||||
|
||||
try {
|
||||
const frameDuration = 1 / detectedFps;
|
||||
const encodeCutToSafe = Math.max(desiredCutFrom + frameDuration, encodeCutTo - frameDuration); // Subtract one frame so we don't end up with duplicates when concating, and make sure we don't create a 0 length segment
|
||||
const frameDuration = getFrameDuration(detectedFps);
|
||||
// Subtract one frame so we don't end up with duplicates when concating, and make sure we don't create a 0 length segment
|
||||
const encodeCutToSafe = Math.max(desiredCutFrom + frameDuration, losslessCutFrom - frameDuration);
|
||||
|
||||
console.log('Cutting/encoding smart part', { from: desiredCutFrom, to: encodeCutToSafe });
|
||||
|
||||
await cutEncodeSmartPartWrapper({ cutFrom: desiredCutFrom, cutTo: encodeCutToSafe, outPath: smartCutEncodedPartOutPath });
|
||||
|
||||
// need to re-read streams because indexes may have changed. Using main file as source of streams and metadata
|
||||
const { streams: streamsAfterCut } = await readFileMeta(smartCutMainPartOutPath);
|
||||
const { streams: streamsAfterCut } = await readFileMeta(losslessPartOutPath);
|
||||
|
||||
const outPath = await makeSegmentOutPath();
|
||||
|
||||
await concatFiles({ paths: smartCutSegmentsToConcat, outDir: outputDir, outPath, metadataFromPath: smartCutMainPartOutPath, outFormat, includeAllStreams: true, streams: streamsAfterCut, ffmpegExperimental, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog, onProgress: onConcatProgress });
|
||||
await concatFiles({ paths: smartCutSegmentsToConcat, outDir: outputDir, outPath, metadataFromPath: losslessPartOutPath, outFormat, includeAllStreams: true, streams: streamsAfterCut, ffmpegExperimental, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog, onProgress: onConcatProgress });
|
||||
return outPath;
|
||||
} finally {
|
||||
await tryDeleteFiles(smartCutSegmentsToConcat);
|
||||
|
@ -439,7 +449,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||
} finally {
|
||||
if (chaptersPath) await tryDeleteFiles([chaptersPath]);
|
||||
}
|
||||
}, [concatFiles, cutSingle, filePath, needSmartCut, shouldSkipExistingFile]);
|
||||
}, [concatFiles, losslessCutSingle, filePath, needSmartCut, shouldSkipExistingFile]);
|
||||
|
||||
const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, appendFfmpegCommandLog, mergedOutFilePath }) => {
|
||||
const outDir = getOutDir(customOutDir, filePath);
|
||||
|
|
|
@ -11,8 +11,8 @@ const configStore = remote.require('./configStore');
|
|||
export default () => {
|
||||
const firstUpdateRef = useRef(true);
|
||||
|
||||
function safeSetConfig(keyValue) {
|
||||
const [key, value] = Object.entries(keyValue)[0];
|
||||
function safeSetConfig(keyValue: Record<string, string>) {
|
||||
const [key, value] = Object.entries(keyValue)[0]!;
|
||||
|
||||
// Prevent flood-saving all config during mount
|
||||
if (firstUpdateRef.current) return;
|
||||
|
@ -26,7 +26,7 @@ export default () => {
|
|||
}
|
||||
}
|
||||
|
||||
function safeGetConfig(key) {
|
||||
function safeGetConfig(key: string) {
|
||||
const rawVal = configStore.get(key);
|
||||
if (rawVal === undefined) return undefined;
|
||||
// NOTE: Need to clone any non-primitive in renderer, or it will become very slow
|
||||
|
@ -37,7 +37,7 @@ export default () => {
|
|||
// From https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
|
||||
// If the initial state is the result of an expensive computation, you may provide a function instead, which will be executed only on the initial render
|
||||
// Without this there was a huge performance issue https://github.com/mifi/lossless-cut/issues/1097
|
||||
const safeGetConfigInitial = (...args) => () => safeGetConfig(...args);
|
||||
const safeGetConfigInitial = (key: string) => () => safeGetConfig(key);
|
||||
|
||||
const [captureFormat, setCaptureFormat] = useState(safeGetConfigInitial('captureFormat'));
|
||||
useEffect(() => safeSetConfig({ captureFormat }), [captureFormat]);
|
||||
|
@ -143,6 +143,8 @@ export default () => {
|
|||
useEffect(() => safeSetConfig({ preferStrongColors }), [preferStrongColors]);
|
||||
const [outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding] = useState(safeGetConfigInitial('outputFileNameMinZeroPadding'));
|
||||
useEffect(() => safeSetConfig({ outputFileNameMinZeroPadding }), [outputFileNameMinZeroPadding]);
|
||||
const [cutFromAdjustmentFrames, setCutFromAdjustmentFrames] = useState(safeGetConfigInitial('cutFromAdjustmentFrames'));
|
||||
useEffect(() => safeSetConfig({ cutFromAdjustmentFrames }), [cutFromAdjustmentFrames]);
|
||||
|
||||
const resetKeyBindings = useCallback(() => {
|
||||
configStore.reset('keyBindings');
|
||||
|
@ -261,5 +263,7 @@ export default () => {
|
|||
setPreferStrongColors,
|
||||
outputFileNameMinZeroPadding,
|
||||
setOutputFileNameMinZeroPadding,
|
||||
cutFromAdjustmentFrames,
|
||||
setCutFromAdjustmentFrames,
|
||||
};
|
||||
};
|
|
@ -1,3 +0,0 @@
|
|||
const { isDev } = window.require('@electron/remote').require('./electron');
|
||||
|
||||
export default isDev;
|
|
@ -0,0 +1,3 @@
|
|||
const { isDev }: { isDev: boolean } = window.require('@electron/remote').require('./electron');
|
||||
|
||||
export default isDev;
|
|
@ -2,7 +2,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|||
import sortBy from 'lodash/sortBy';
|
||||
import minBy from 'lodash/minBy';
|
||||
import maxBy from 'lodash/maxBy';
|
||||
import { InverseSegment } from './types';
|
||||
import { InverseSegment, SegmentBase } from './types';
|
||||
|
||||
|
||||
export const isDurationValid = (duration?: number): duration is number => duration != null && Number.isFinite(duration) && duration > 0;
|
||||
|
@ -66,7 +66,7 @@ export function partitionIntoOverlappingRanges(array, getSegmentStart = (seg) =>
|
|||
return getSegmentEnd(array2[0]);
|
||||
}
|
||||
|
||||
const ret: number[][] = [];
|
||||
const ret: SegmentBase[][] = [];
|
||||
let g = 0;
|
||||
ret[g] = [array[0]];
|
||||
|
||||
|
@ -95,14 +95,14 @@ export function combineOverlappingSegments(existingSegments, getSegApparentEnd2)
|
|||
return {
|
||||
...existingSegment,
|
||||
// but use the segment with the highest "end" value as the end value.
|
||||
end: sortBy(partOfPartition, (segment) => segment.end)[partOfPartition.length - 1].end,
|
||||
end: sortBy(partOfPartition, (segment) => segment.end)[partOfPartition.length - 1]!.end,
|
||||
};
|
||||
}
|
||||
return undefined; // then remove all other segments in this partition group
|
||||
}).filter((segment) => segment);
|
||||
}
|
||||
|
||||
export function combineSelectedSegments(existingSegments, getSegApparentEnd2, isSegmentSelected) {
|
||||
export function combineSelectedSegments<T extends SegmentBase>(existingSegments: T[], getSegApparentEnd2, isSegmentSelected) {
|
||||
const selectedSegments = existingSegments.filter(isSegmentSelected);
|
||||
const firstSegment = minBy(selectedSegments, (seg) => getSegApparentStart(seg));
|
||||
const lastSegment = maxBy(selectedSegments, (seg) => getSegApparentEnd2(seg));
|
||||
|
@ -112,7 +112,7 @@ export function combineSelectedSegments(existingSegments, getSegApparentEnd2, is
|
|||
return {
|
||||
...firstSegment,
|
||||
start: firstSegment.start,
|
||||
end: lastSegment.end,
|
||||
end: lastSegment!.end,
|
||||
};
|
||||
}
|
||||
if (isSegmentSelected(existingSegment)) return undefined; // remove other selected segments
|
||||
|
|
|
@ -27,7 +27,7 @@ export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, s
|
|||
console.log('Start cut is already on exact keyframe', keyframeAtExactTime.time);
|
||||
|
||||
return {
|
||||
cutFrom: keyframeAtExactTime.time,
|
||||
losslessCutFrom: keyframeAtExactTime.time,
|
||||
videoStreamIndex: videoStream.index,
|
||||
segmentNeedsSmartCut: false,
|
||||
};
|
||||
|
@ -69,7 +69,7 @@ export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, s
|
|||
// const videoProfile = parseProfile(videoStream);
|
||||
|
||||
return {
|
||||
cutFrom: nextKeyframe.time,
|
||||
losslessCutFrom: nextKeyframe.time,
|
||||
videoStreamIndex: videoStream.index,
|
||||
segmentNeedsSmartCut: true,
|
||||
videoCodec,
|
||||
|
|
13
src/types.ts
13
src/types.ts
|
@ -28,3 +28,16 @@ export interface Waveform {
|
|||
}
|
||||
|
||||
export type FfmpegCommandLog = { command: string, time: Date }[];
|
||||
|
||||
export interface Thumbnail {
|
||||
time: number
|
||||
url: string
|
||||
}
|
||||
|
||||
// todo types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FfprobeStream = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FfprobeFormat = any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type FfprobeChapter = any;
|
||||
|
|
16
src/util.ts
16
src/util.ts
|
@ -18,9 +18,9 @@ const { ipcRenderer } = window.require('electron');
|
|||
const remote = window.require('@electron/remote');
|
||||
|
||||
|
||||
const trashFile = async (path) => ipcRenderer.invoke('tryTrashItem', path);
|
||||
const trashFile = async (path: string) => ipcRenderer.invoke('tryTrashItem', path);
|
||||
|
||||
export const showItemInFolder = async (path) => ipcRenderer.invoke('showItemInFolder', path);
|
||||
export const showItemInFolder = async (path: string) => ipcRenderer.invoke('showItemInFolder', path);
|
||||
|
||||
|
||||
export function getFileDir(filePath?: string) {
|
||||
|
@ -33,25 +33,25 @@ export function getOutDir(customOutDir?: string, filePath?: string) {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function getFileBaseName(filePath) {
|
||||
function getFileBaseName(filePath?: string) {
|
||||
if (!filePath) return undefined;
|
||||
const parsed = parsePath(filePath);
|
||||
return parsed.name;
|
||||
}
|
||||
|
||||
export function getOutPath({ customOutDir, filePath, fileName }) {
|
||||
export function getOutPath({ customOutDir, filePath, fileName }: { customOutDir?: string, filePath?: string, fileName?: string }) {
|
||||
if (!filePath) return undefined;
|
||||
return join(getOutDir(customOutDir, filePath), fileName);
|
||||
}
|
||||
|
||||
export const getSuffixedFileName = (filePath, nameSuffix) => `${getFileBaseName(filePath)}-${nameSuffix}`;
|
||||
export const getSuffixedFileName = (filePath: string | undefined, nameSuffix: string) => `${getFileBaseName(filePath)}-${nameSuffix}`;
|
||||
|
||||
export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }) {
|
||||
export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }: { customOutDir?: string, filePath?: string, nameSuffix: string }) {
|
||||
if (!filePath) return undefined;
|
||||
return getOutPath({ customOutDir, filePath, fileName: getSuffixedFileName(filePath, nameSuffix) });
|
||||
}
|
||||
|
||||
export async function havePermissionToReadFile(filePath) {
|
||||
export async function havePermissionToReadFile(filePath: string) {
|
||||
try {
|
||||
const fd = await fsExtra.open(filePath, 'r');
|
||||
try {
|
||||
|
@ -121,6 +121,8 @@ export const unlinkWithRetry = async (path, options) => fsOperationWithRetry(asy
|
|||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const utimesWithRetry = async (path: string, atime: number, mtime: number, options?: any) => fsOperationWithRetry(async () => utimes(path, atime, mtime), { ...options, onFailedAttempt: (error) => console.warn('Retrying utimes', path, error.attemptNumber) });
|
||||
|
||||
export const getFrameDuration = (fps?: number) => 1 / (fps ?? 30);
|
||||
|
||||
export async function transferTimestamps({ inPath, outPath, cutFrom = 0, cutTo = 0, duration = 0, treatInputFileModifiedTimeAsStart = true, treatOutputFileModifiedTimeAsStart }) {
|
||||
if (treatOutputFileModifiedTimeAsStart == null) return; // null means disabled;
|
||||
|
||||
|
|
|
@ -13,11 +13,11 @@ export function formatDuration({ seconds: totalSecondsIn, fileNameFriendly, show
|
|||
const totalUnits = Math.round(totalSecondsAbs * unitsPerSec);
|
||||
|
||||
const seconds = Math.floor(totalUnits / unitsPerSec);
|
||||
const secondsPadded = padStart(seconds % 60, 2, '0');
|
||||
const secondsPadded = padStart(String(seconds % 60), 2, '0');
|
||||
const minutes = Math.floor(totalUnits / unitsPerSec / 60) % 60;
|
||||
const hours = Math.floor(totalUnits / unitsPerSec / 60 / 60);
|
||||
|
||||
const minutesPadded = shorten && hours === 0 ? `${minutes}` : padStart(minutes, 2, '0');
|
||||
const minutesPadded = shorten && hours === 0 ? `${minutes}` : padStart(String(minutes), 2, '0');
|
||||
|
||||
const remainder = totalUnits % unitsPerSec;
|
||||
|
||||
|
@ -26,14 +26,14 @@ export function formatDuration({ seconds: totalSecondsIn, fileNameFriendly, show
|
|||
|
||||
let hoursPart = '';
|
||||
if (!shorten || hours !== 0) {
|
||||
const hoursPadded = shorten ? `${hours}` : padStart(hours, 2, '0');
|
||||
const hoursPadded = shorten ? `${hours}` : padStart(String(hours), 2, '0');
|
||||
hoursPart = `${hoursPadded}${delim}`;
|
||||
}
|
||||
|
||||
let fraction = '';
|
||||
if (showFraction && !(shorten && remainder === 0)) {
|
||||
const numDigits = fps != null ? 2 : 3;
|
||||
fraction = `.${padStart(Math.floor(remainder), numDigits, '0')}`;
|
||||
fraction = `.${padStart(String(Math.floor(remainder)), numDigits, '0')}`;
|
||||
}
|
||||
|
||||
return `${sign}${hoursPart}${minutesPadded}${delim}${secondsPadded}${fraction}`;
|
||||
|
|
|
@ -159,7 +159,7 @@ export function generateOutSegFileNames({ segments, template: desiredTemplate, f
|
|||
return [
|
||||
...rest,
|
||||
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
|
||||
safeOutputFileName ? lastSeg.substring(0, 200) : lastSeg,
|
||||
safeOutputFileName ? lastSeg!.substring(0, 200) : lastSeg,
|
||||
].join(pathSep);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@
|
|||
"compilerOptions": {
|
||||
"plugins": [{ "name": "typescript-plugin-css-modules" }],
|
||||
|
||||
"lib": ["es2023", "DOM", "DOM.Iterable"],
|
||||
|
||||
"exactOptionalPropertyTypes": false, // todo
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noEmit": true,
|
||||
|
|
|
@ -1751,6 +1751,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/lodash@npm:^4.14.202":
|
||||
version: 4.14.202
|
||||
resolution: "@types/lodash@npm:4.14.202"
|
||||
checksum: 1bb9760a5b1dda120132c4b987330d67979c95dbc22612678682cd61b00302e190f4207228f3728580059cdab5582362262e3819aea59960c1017bd2b9fb26f6
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/minimatch@npm:^3.0.3":
|
||||
version: 3.0.5
|
||||
resolution: "@types/minimatch@npm:3.0.5"
|
||||
|
@ -7149,6 +7156,7 @@ __metadata:
|
|||
"@radix-ui/react-switch": "npm:^1.0.1"
|
||||
"@tsconfig/strictest": "npm:^2.0.2"
|
||||
"@tsconfig/vite-react": "npm:^3.0.0"
|
||||
"@types/lodash": "npm:^4.14.202"
|
||||
"@types/sortablejs": "npm:^1.15.0"
|
||||
"@typescript-eslint/eslint-plugin": "npm:^6.17.0"
|
||||
"@typescript-eslint/parser": "npm:^6.17.0"
|
||||
|
|
Ładowanie…
Reference in New Issue