add option to shift cut start frames

and add types
pull/1899/head
Mikael Finstad 2024-02-14 21:12:16 +08:00
rodzic 37af026932
commit 9509680c03
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
17 zmienionych plików z 151 dodań i 73 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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]) => [

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,3 +0,0 @@
const { isDev } = window.require('@electron/remote').require('./electron');
export default isDev;

3
src/isDev.ts 100644
Wyświetl plik

@ -0,0 +1,3 @@
const { isDev }: { isDev: boolean } = window.require('@electron/remote').require('./electron');
export default isDev;

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,8 @@
"compilerOptions": {
"plugins": [{ "name": "typescript-plugin-css-modules" }],
"lib": ["es2023", "DOM", "DOM.Iterable"],
"exactOptionalPropertyTypes": false, // todo
"noUncheckedIndexedAccess": true,
"noEmit": true,

Wyświetl plik

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