implement decimate

closes #2111
pull/2867/head
Mikael Finstad 1 month ago
parent 26053137d6
commit 260531016d
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -176,6 +176,9 @@
"Cutpoints may be inaccurate.": "Cutpoints may be inaccurate.",
"Data": "Data",
"DaVinci Resolve / Final Cut Pro XML": "DaVinci Resolve / Final Cut Pro XML",
"Decimate allows you to losslessly extract only the keyframes from a video, discarding all non-keyframes. You can also optionally drop some of the keyframes. This can dramatically speed up playback and is useful for shortening long videos with little motion, such as creating time-lapse videos.": "Decimate allows you to losslessly extract only the keyframes from a video, discarding all non-keyframes. You can also optionally drop some of the keyframes. This can dramatically speed up playback and is useful for shortening long videos with little motion, such as creating time-lapse videos.",
"Decimate video": "Decimate video",
"Decimating video": "Decimating video",
"Decrease audio volume": "Decrease audio volume",
"Decrease font size": "Decrease font size",
"Decrease segment order": "Decrease segment order",
@ -312,7 +315,6 @@
"Failed to find any prev frame": "Failed to find any prev frame",
"Failed to find any prev keyframe": "Failed to find any prev keyframe",
"Failed to find next keyframe": "Failed to find next keyframe",
"Failed to fix file duration": "Failed to fix file duration",
"Failed to import project file": "Failed to import project file",
"Failed to include track": "Failed to include track",
"Failed to load segments": "Failed to load segments",
@ -415,6 +417,7 @@
"Jump to start time": "Jump to start time",
"kbit/s": "kbit/s",
"Keep": "Keep",
"Keep every nth keyframe. Use the value \"1\" to keep all keyframes": "Keep every nth keyframe. Use the value \"1\" to keep all keyframes",
"Keep or discard all tracks": "Keep or discard all tracks",
"Keep or discard audio tracks": "Keep or discard audio tracks",
"Keep or discard subtitle tracks": "Keep or discard subtitle tracks",
@ -551,6 +554,7 @@
"Output file names are not sanitized. Try to enable sanitazion or check your segment labels for invalid characters.": "Output file names are not sanitized. Try to enable sanitazion or check your segment labels for invalid characters.",
"Output name(s):_one": "Output file name:",
"Output name(s):_other": "Output file names:",
"Output video frame rate (frames per second)": "Output video frame rate (frames per second)",
"Overwrite existing files": "Overwrite existing files",
"Overwrite files when exporting, if a file with the same name as the output file name exists?": "Overwrite files when exporting, if a file with the same name as the output file name exists",
"Overwrite output setting is disabled and some files might have been skipped.": "Overwrite output setting is disabled and some files might have been skipped.",
@ -764,6 +768,7 @@
"The last executed ffmpeg commands will show up here after you run operations. You can copy them to clipboard and modify them to your needs before running on your command line.": "The last executed ffmpeg commands will show up here after you run operations. You can copy them to clipboard and modify them to your needs before running on your command line.",
"The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}": "The media file referenced by the project file you tried to open does not exist in the same directory as the project file: {{mediaFileName}}",
"The media you tried to open does not exist": "The media you tried to open does not exist",
"The operation failed": "The operation failed",
"The output location has no storage space remaining. Please free up some space and try again.": "The output location has no storage space remaining. Please free up some space and try again.",
"The size of the merged output file ({{outputFileTotalSize}}) differs from the total size of source files ({{sourceFilesTotalSize}}) by more than {{maxDiffPercent}}%. This could indicate that there was a problem during the merge.": "The size of the merged output file ({{outputFileTotalSize}}) differs from the total size of source files ({{sourceFilesTotalSize}}) by more than {{maxDiffPercent}}%. This could indicate that there was a problem during the merge.",
"The video inside segments will be discarded, while the video surrounding them will be kept.": "The video inside segments will be discarded, while the video surrounding them will be kept.",

@ -1,6 +1,6 @@
import type { SupportedLanguage } from './i18n.ts';
export type KeyboardAction = 'addSegment' | 'togglePlayResetSpeed' | 'togglePlayNoResetSpeed' | 'reducePlaybackRate' | 'reducePlaybackRateMore' | 'increasePlaybackRate' | 'increasePlaybackRateMore' | 'timelineToggleComfortZoom' | 'seekPreviousFrame' | 'seekNextFrame' | 'captureSnapshot' | 'captureSnapshotToClipboard' | 'setCutStart' | 'setCutEnd' | 'removeCurrentSegment' | 'removeCurrentCutpoint' | 'cleanupFilesDialog' | 'splitCurrentSegment' | 'focusSegmentAtCursor' | 'selectSegmentsAtCursor' | 'increaseRotation' | 'goToTimecode' | 'seekBackwards' | 'seekBackwards2' | 'seekBackwards3' | 'seekBackwardsPercent' | 'seekBackwardsPercent' | 'seekBackwardsKeyframe' | 'jumpCutStart' | 'seekForwards' | 'seekForwards2' | 'seekForwards3' | 'seekForwardsPercent' | 'seekForwardsPercent' | 'seekForwardsKeyframe' | 'jumpCutEnd' | 'jumpTimelineStart' | 'jumpTimelineEnd' | 'jumpFirstSegment' | 'jumpPrevSegment' | 'jumpSeekFirstSegment' | 'jumpSeekPrevSegment' | 'timelineZoomIn' | 'timelineZoomIn' | 'batchPreviousFile' | 'jumpLastSegment' | 'jumpNextSegment' | 'jumpSeekLastSegment' | 'jumpSeekNextSegment' | 'timelineZoomOut' | 'timelineZoomOut' | 'batchNextFile' | 'batchOpenSelectedFile' | 'batchOpenPreviousFile' | 'batchOpenNextFile' | 'undo' | 'undo' | 'redo' | 'redo' | 'copySegmentsToClipboard' | 'copySegmentsToClipboard' | 'toggleFullscreenVideo' | 'labelCurrentSegment' | 'export' | 'toggleKeyboardShortcuts' | 'increaseVolume' | 'decreaseVolume' | 'toggleMuted' | 'detectBlackScenes' | 'detectSilentScenes' | 'detectSceneChanges' | 'toggleLastCommands' | 'play' | 'pause' | 'reloadFile' | 'html5ify' | 'makeCursorTimeZero' | 'togglePlayOnlyCurrentSegment' | 'toggleLoopOnlyCurrentSegment' | 'toggleLoopStartEndOnlyCurrentSegment' | 'togglePlaySelectedSegments' | 'toggleLoopSelectedSegments' | 'editCurrentSegmentTags' | 'duplicateCurrentSegment' | 'reorderSegsByStartTime' | 'invertAllSegments' | 'fillSegmentsGaps' | 'shiftAllSegmentTimes' | 'alignSegmentTimesToKeyframes' | 'readAllKeyframes' | 'createSegmentsFromKeyframes' | 'createFixedDurationSegments' | 'createNumSegments' | 'createFixedByteSizedSegments' | 'createRandomSegments' | 'shuffleSegments' | 'combineOverlappingSegments' | 'combineSelectedSegments' | 'clearSegments' | 'toggleSegmentsList' | 'selectOnlyCurrentSegment' | 'deselectAllSegments' | 'selectAllSegments' | 'toggleCurrentSegmentSelected' | 'invertSelectedSegments' | 'removeSelectedSegments' | 'toggleStreamsSelector' | 'extractAllStreams' | 'showStreamsSelector' | 'showIncludeExternalStreamsDialog' | 'captureSnapshotAsCoverArt' | 'extractCurrentSegmentFramesAsImages' | 'extractSelectedSegmentsFramesAsImages' | 'convertFormatBatch' | 'convertFormatCurrentFile' | 'fixInvalidDuration' | 'closeBatch' | 'concatBatch' | 'toggleKeyframeCutMode' | 'toggleCaptureFormat' | 'toggleStripAudio' | 'toggleStripVideo' | 'toggleStripSubtitle' | 'toggleStripThumbnail' | 'toggleStripCurrentFilter' | 'toggleStripAll' | 'toggleDarkMode' | 'setStartTimeOffset' | 'toggleWaveformMode' | 'toggleShowThumbnails' | 'toggleShowKeyframes' | 'toggleSettings' | 'openSendReportDialog' | 'openFilesDialog' | 'openDirDialog' | 'exportYouTube' | 'closeCurrentFile' | 'quit' | 'selectAllMarkers' | 'generateOverviewWaveform' | 'selectSegmentsByLabel' | 'selectSegmentsByExpr' | 'labelSelectedSegments' | 'mutateSegmentsByExpr';
export type KeyboardAction = 'addSegment' | 'togglePlayResetSpeed' | 'togglePlayNoResetSpeed' | 'reducePlaybackRate' | 'reducePlaybackRateMore' | 'increasePlaybackRate' | 'increasePlaybackRateMore' | 'timelineToggleComfortZoom' | 'seekPreviousFrame' | 'seekNextFrame' | 'captureSnapshot' | 'captureSnapshotToClipboard' | 'setCutStart' | 'setCutEnd' | 'removeCurrentSegment' | 'removeCurrentCutpoint' | 'cleanupFilesDialog' | 'splitCurrentSegment' | 'focusSegmentAtCursor' | 'selectSegmentsAtCursor' | 'increaseRotation' | 'goToTimecode' | 'seekBackwards' | 'seekBackwards2' | 'seekBackwards3' | 'seekBackwardsPercent' | 'seekBackwardsPercent' | 'seekBackwardsKeyframe' | 'jumpCutStart' | 'seekForwards' | 'seekForwards2' | 'seekForwards3' | 'seekForwardsPercent' | 'seekForwardsPercent' | 'seekForwardsKeyframe' | 'jumpCutEnd' | 'jumpTimelineStart' | 'jumpTimelineEnd' | 'jumpFirstSegment' | 'jumpPrevSegment' | 'jumpSeekFirstSegment' | 'jumpSeekPrevSegment' | 'timelineZoomIn' | 'timelineZoomIn' | 'batchPreviousFile' | 'jumpLastSegment' | 'jumpNextSegment' | 'jumpSeekLastSegment' | 'jumpSeekNextSegment' | 'timelineZoomOut' | 'timelineZoomOut' | 'batchNextFile' | 'batchOpenSelectedFile' | 'batchOpenPreviousFile' | 'batchOpenNextFile' | 'undo' | 'undo' | 'redo' | 'redo' | 'copySegmentsToClipboard' | 'copySegmentsToClipboard' | 'toggleFullscreenVideo' | 'labelCurrentSegment' | 'export' | 'toggleKeyboardShortcuts' | 'increaseVolume' | 'decreaseVolume' | 'toggleMuted' | 'detectBlackScenes' | 'detectSilentScenes' | 'detectSceneChanges' | 'toggleLastCommands' | 'play' | 'pause' | 'reloadFile' | 'html5ify' | 'makeCursorTimeZero' | 'togglePlayOnlyCurrentSegment' | 'toggleLoopOnlyCurrentSegment' | 'toggleLoopStartEndOnlyCurrentSegment' | 'togglePlaySelectedSegments' | 'toggleLoopSelectedSegments' | 'editCurrentSegmentTags' | 'duplicateCurrentSegment' | 'reorderSegsByStartTime' | 'invertAllSegments' | 'fillSegmentsGaps' | 'shiftAllSegmentTimes' | 'alignSegmentTimesToKeyframes' | 'readAllKeyframes' | 'createSegmentsFromKeyframes' | 'createFixedDurationSegments' | 'createNumSegments' | 'createFixedByteSizedSegments' | 'createRandomSegments' | 'shuffleSegments' | 'combineOverlappingSegments' | 'combineSelectedSegments' | 'clearSegments' | 'toggleSegmentsList' | 'selectOnlyCurrentSegment' | 'deselectAllSegments' | 'selectAllSegments' | 'toggleCurrentSegmentSelected' | 'invertSelectedSegments' | 'removeSelectedSegments' | 'toggleStreamsSelector' | 'extractAllStreams' | 'showStreamsSelector' | 'showIncludeExternalStreamsDialog' | 'captureSnapshotAsCoverArt' | 'extractCurrentSegmentFramesAsImages' | 'extractSelectedSegmentsFramesAsImages' | 'convertFormatBatch' | 'convertFormatCurrentFile' | 'fixInvalidDuration' | 'decimate' | 'closeBatch' | 'concatBatch' | 'toggleKeyframeCutMode' | 'toggleCaptureFormat' | 'toggleStripAudio' | 'toggleStripVideo' | 'toggleStripSubtitle' | 'toggleStripThumbnail' | 'toggleStripCurrentFilter' | 'toggleStripAll' | 'toggleDarkMode' | 'setStartTimeOffset' | 'toggleWaveformMode' | 'toggleShowThumbnails' | 'toggleShowKeyframes' | 'toggleSettings' | 'openSendReportDialog' | 'openFilesDialog' | 'openDirDialog' | 'exportYouTube' | 'closeCurrentFile' | 'quit' | 'selectAllMarkers' | 'generateOverviewWaveform' | 'selectSegmentsByLabel' | 'selectSegmentsByExpr' | 'labelSelectedSegments' | 'mutateSegmentsByExpr';
export interface KeyBinding {
keys: string,

@ -8,7 +8,7 @@ export const getHwaccelArgs = (hwaccel: FfmpegHwAccel) => (hwaccel !== 'none' ?
// Used to be 5, but we recently increased to 6 because https://github.com/mifi/lossless-cut/issues/2838
// I don't remember why 5 was chosen initially, but if we don't truncate, ffmpeg can sometimes give an error when too many decimal places are used in the time argument, see:
export const formatFfmpegTime = (time: number) => time.toFixed(6);
export const formatFfmpegNumber = (time: number) => time.toFixed(6);
export function parseRatio(str: string, char = '/') {
const split = str.split(char);

@ -14,7 +14,7 @@ import type { FFprobeFormat } from '../common/ffprobe.js';
import isDev from './isDev.js';
import logger from './logger.js';
import { parseFfmpegProgressLine } from './progress.js';
import { formatFfmpegTime, getHwaccelArgs, parseFfprobeDuration } from '../common/util.js';
import { formatFfmpegNumber, getHwaccelArgs, parseFfprobeDuration } from '../common/util.js';
import { getFfmpegJpegQuality } from './ffmpegUtil.js';
@ -264,9 +264,9 @@ export async function renderWaveformPng({ filePath, start, duration, resample, c
}
const getInputSeekArgs = ({ filePath, from, to }: { filePath: string, from?: number | undefined, to?: number | undefined }) => [
...(from != null ? ['-ss', formatFfmpegTime(from)] : []),
...(from != null ? ['-ss', formatFfmpegNumber(from)] : []),
'-i', filePath,
...(from != null && to != null ? ['-t', formatFfmpegTime(to - from)] : []),
...(from != null && to != null ? ['-t', formatFfmpegNumber(to - from)] : []),
];
export function mapTimesToSegments(times: number[], includeLast: boolean) {
@ -748,5 +748,5 @@ export async function downloadMediaUrl(url: string, outPath: string) {
await runFfmpegProcess(args);
}
// Don't pass complex objects over the bridge (the process), so just convert it to a promise
// Don't pass complex objects (execa decorated promise) over the bridge (the process). Instead convert it to a normal promise
export const runFfmpeg = async (...args: Parameters<typeof runFfmpegProcess>) => runFfmpegProcess(...args);

@ -201,6 +201,12 @@ export default ({ app, mainWindow, newVersion, isStoreBuild }: {
mainWindow.webContents.send('fixInvalidDuration');
},
},
{
label: esc(t('Decimate video')),
click() {
mainWindow.webContents.send('decimate');
},
},
{ type: 'separator' },
{ type: 'separator' },

@ -80,6 +80,9 @@ import {
isMasBuild,
readFileStats,
makeSourceFileAccessError,
transferTimestamps,
getOutFileExtension,
getSuffixedOutPath,
} from './util';
import getSwal, { errorToast, showPlaybackFailedMessage } from './swal';
import { adjustRate } from './util/rate-calculator';
@ -195,7 +198,7 @@ function App() {
const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling();
const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog } = useDialog();
const { showGenericDialog, genericDialog, closeGenericDialog, confirmDialog, openExportFinishedDialog, openCutFinishedDialog, openConcatFinishedDialog, openCleanupFilesDialog, openShiftSegmentsDialog, openDecimateDialog } = useDialog();
// Note that each action may be multiple key bindings and this will only be the first binding for each action
const keyBindingByAction = useMemo(() => Object.fromEntries(keyBindings.map((binding) => [binding.action, binding])), [keyBindings]);
@ -590,7 +593,7 @@ function App() {
const isEncoding = needSmartCut || lossyMode != null;
const {
concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, extractStreams, tryDeleteFiles,
concatFiles, html5ifyDummy, cutMultiple, concatCutSegments, html5ify, fixInvalidDuration, decimate, extractStreams, tryDeleteFiles,
} = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate: encBitrate, appendFfmpegCommandLog, ffmpegHwaccel });
const { previewFilePath, setPreviewFilePath, usingDummyVideo, setUsingDummyVideo, userHtml5ifyCurrentFile, convertFormatBatch, html5ifyAndLoadWithPreferences } = useHtml5ify({
@ -1715,23 +1718,47 @@ function App() {
const toggleKeyboardShortcuts = useCallback(() => setKeyboardShortcutsVisible((v) => !v), []);
const tryFixInvalidDuration = useCallback(async () => {
const runAndReloadFile = useCallback(async ({ operation, loadingText, errorText = i18n.t('The operation failed'), nameSuffix }: { operation: (params: { filePath: string, outPath: string }) => Promise<string>, loadingText: string, errorText?: string, nameSuffix: string }) => {
if (!checkFileOpened() || workingRef.current) return;
try {
setWorking({ text: i18n.t('Fixing file duration') });
setWorking({ text: loadingText });
setProgress(0);
await withErrorHandling(async () => {
invariant(fileFormat != null);
const path = await fixInvalidDuration({ fileFormat, customOutDir, onProgress: setProgress });
showNotification({ icon: 'info', text: i18n.t('Duration has been fixed') });
invariant(filePath != null);
const ext = getOutFileExtension({ outFormat: fileFormat, filePath });
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `${nameSuffix}${ext}` });
const newPath = await operation({ filePath, outPath });
await transferTimestamps({ inPath: filePath, outPath, duration: undefined, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart });
await loadMedia({ filePath: path });
}, i18n.t('Failed to fix file duration'));
await loadMedia({ filePath: newPath });
}, errorText);
} finally {
setWorking(undefined);
setProgress(undefined);
}
}, [checkFileOpened, customOutDir, fileFormat, fixInvalidDuration, loadMedia, setWorking, showNotification, withErrorHandling, workingRef]);
}, [checkFileOpened, customOutDir, fileFormat, filePath, loadMedia, setWorking, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, withErrorHandling, workingRef]);
const tryFixInvalidDuration = useCallback(async () => runAndReloadFile({
operation: async ({ filePath: fp, outPath }) => {
const path = await fixInvalidDuration({ filePath: fp, outPath, onProgress: setProgress });
showNotification({ icon: 'info', text: i18n.t('Duration has been fixed') });
return path;
},
loadingText: i18n.t('Fixing file duration'),
nameSuffix: 'reformatted',
}), [fixInvalidDuration, runAndReloadFile, showNotification]);
const tryDecimate = useCallback(async () => {
if (!checkFileOpened()) return;
const params = await openDecimateDialog();
if (params == null) return;
await runAndReloadFile({
operation: async ({ filePath: fp, outPath }) => decimate({ filePath: fp, outPath, ...params }),
loadingText: i18n.t('Decimating video'),
nameSuffix: 'decimated',
});
}, [checkFileOpened, decimate, openDecimateDialog, runAndReloadFile]);
const addStreamSourceFile = useCallback(async (path: string) => {
if (allFilesMeta[path]) return undefined; // Already added?
@ -2108,6 +2135,7 @@ function App() {
invertSelectedSegments,
removeSelectedSegments,
fixInvalidDuration: tryFixInvalidDuration,
decimate: tryDecimate,
shiftAllSegmentTimes,
increaseVolume: () => setPlaybackVolume((val) => Math.min(1, val + 0.07)),
decreaseVolume: () => setPlaybackVolume((val) => Math.max(0, val - 0.07)),
@ -2146,7 +2174,7 @@ function App() {
};
return ret;
}, [togglePlaySelectedSegments, toggleLoopSelectedSegments, pause, timelineToggleComfortZoom, captureSnapshot, captureSnapshotAsCoverArt, captureSnapshotToClipboard, setCutStart, setCutEnd, cleanupFilesDialog, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, increaseRotation, jumpCutStart, jumpCutEnd, jumpTimelineStart, jumpTimelineEnd, batchOpenSelectedFile, closeBatch, addSegment, duplicateCurrentSegment, toggleLastCommands, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, createFixedDurationSegments, createNumSegments, createFixedByteSizedSegments, createRandomSegments, alignSegmentTimesToKeyframes, shuffleSegments, clearSegments, toggleSegmentsList, toggleStreamsSelector, extractAllStreams, convertFormatBatch, concatBatch, toggleCaptureFormat, toggleStripAudio, toggleStripVideo, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, toggleDarkMode, askStartTimeOffset, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, editCurrentSegmentTags, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, tryFixInvalidDuration, shiftAllSegmentTimes, toggleMuted, copySegmentsToClipboard, handleShowStreamsSelectorClick, openFilesDialog, openDirDialog, toggleSettings, detectBlackScenes, detectSilentScenes, detectSceneChanges, readAllKeyframes, createSegmentsFromKeyframes, toggleWaveformMode, toggleShowThumbnails, toggleShowKeyframes, showIncludeExternalStreamsDialog, toggleFullscreenVideo, selectAllMarkers, selectSegmentsByLabel, selectSegmentsByExpr, labelSelectedSegments, mutateSegmentsByExpr, toggleKeyboardShortcuts, generateOverviewWaveform, checkFileOpened, cutSegments, seekRel, keyboardSeekAccFactor, togglePlay, play, userChangePlaybackRate, goToTimecode, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, seekRelPercent, seekClosestKeyframe, shortStep, jumpSeg, zoomRel, batchFileJump, removeSegment, currentSegIndexSafe, cutSegmentsHistory, labelSegment, onExportPress, userHtml5ifyCurrentFile, toggleKeyframeCut, applyEnabledStreamsFilter, setPlaybackVolume, commandedTimeRef, closeFileWithConfirm, openSendReportDialogWithState]);
}, [togglePlaySelectedSegments, toggleLoopSelectedSegments, pause, timelineToggleComfortZoom, captureSnapshot, captureSnapshotAsCoverArt, captureSnapshotToClipboard, setCutStart, setCutEnd, cleanupFilesDialog, splitCurrentSegment, focusSegmentAtCursor, selectSegmentsAtCursor, increaseRotation, jumpCutStart, jumpCutEnd, jumpTimelineStart, jumpTimelineEnd, batchOpenSelectedFile, closeBatch, addSegment, duplicateCurrentSegment, toggleLastCommands, extractCurrentSegmentFramesAsImages, extractSelectedSegmentsFramesAsImages, reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, combineOverlappingSegments, combineSelectedSegments, createFixedDurationSegments, createNumSegments, createFixedByteSizedSegments, createRandomSegments, alignSegmentTimesToKeyframes, shuffleSegments, clearSegments, toggleSegmentsList, toggleStreamsSelector, extractAllStreams, convertFormatBatch, concatBatch, toggleCaptureFormat, toggleStripAudio, toggleStripVideo, toggleStripSubtitle, toggleStripThumbnail, toggleStripAll, toggleDarkMode, askStartTimeOffset, deselectAllSegments, selectAllSegments, selectOnlyCurrentSegment, editCurrentSegmentTags, toggleCurrentSegmentSelected, invertSelectedSegments, removeSelectedSegments, tryFixInvalidDuration, tryDecimate, shiftAllSegmentTimes, toggleMuted, copySegmentsToClipboard, handleShowStreamsSelectorClick, openFilesDialog, openDirDialog, toggleSettings, detectBlackScenes, detectSilentScenes, detectSceneChanges, readAllKeyframes, createSegmentsFromKeyframes, toggleWaveformMode, toggleShowThumbnails, toggleShowKeyframes, showIncludeExternalStreamsDialog, toggleFullscreenVideo, selectAllMarkers, selectSegmentsByLabel, selectSegmentsByExpr, labelSelectedSegments, mutateSegmentsByExpr, toggleKeyboardShortcuts, generateOverviewWaveform, checkFileOpened, cutSegments, seekRel, keyboardSeekAccFactor, togglePlay, play, userChangePlaybackRate, goToTimecode, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, seekRelPercent, seekClosestKeyframe, shortStep, jumpSeg, zoomRel, batchFileJump, removeSegment, currentSegIndexSafe, cutSegmentsHistory, labelSegment, onExportPress, userHtml5ifyCurrentFile, toggleKeyframeCut, applyEnabledStreamsFilter, setPlaybackVolume, commandedTimeRef, closeFileWithConfirm, openSendReportDialogWithState]);
const getKeyboardAction = useCallback((action: MainKeyboardAction) => mainActions[action], [mainActions]);

@ -376,6 +376,62 @@ export function useDialog() {
});
}), [showGenericDialog, t]);
const openDecimateDialog = useCallback(() => new Promise<{ n: number, fps: number } | undefined>((resolve) => {
function DecimateDialog() {
const { onOpenChange } = useGenericDialogContext();
const [fpsStr, setFps] = useState('20');
const [nStr, setN] = useState('1');
const handleSubmit = useCallback<FormEventHandler<HTMLFormElement>>((e) => {
e.preventDefault();
try {
const n = Number(nStr);
const fps = Number(fpsStr);
invariant(!Number.isNaN(n) && n > 0);
invariant(!Number.isNaN(fps) && fps > 0);
resolve({ n, fps });
onOpenChange(false);
} catch (err) {
console.warn(err);
}
}, [fpsStr, nStr, onOpenChange]);
return (
<Dialog.Content aria-describedby={undefined} style={{ width: '50vw' }}>
<Dialog.Title>{t('Decimate video')}</Dialog.Title>
<Dialog.Description>{t('Decimate allows you to losslessly extract only the keyframes from a video, discarding all non-keyframes. You can also optionally drop some of the keyframes. This can dramatically speed up playback and is useful for shortening long videos with little motion, such as creating time-lapse videos.')}</Dialog.Description>
<form onSubmit={handleSubmit}>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.5em' }}>
{t('Keep every nth keyframe. Use the value "1" to keep all keyframes')}<br />
<TextField.Root value={nStr} placeholder="n" onChange={(e) => setN(e.target.value)} />
</label>
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label style={{ display: 'block', marginBottom: '.5em' }}>
{t('Output video frame rate (frames per second)')}<br />
<TextField.Root value={fpsStr} onChange={(e) => setFps(e.target.value)} />
</label>
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton>{t('Cancel')}</DialogButton>
</Dialog.Close>
<DialogButton type="submit" primary>{t('Confirm')}</DialogButton>
</Dialog.ButtonRow>
</form>
</Dialog.Content>
);
}
showGenericDialog({
render: () => <DecimateDialog />,
onClose: () => resolve(undefined),
});
}), [showGenericDialog, t]);
return {
genericDialog,
closeGenericDialog,
@ -386,6 +442,7 @@ export function useDialog() {
openConcatFinishedDialog,
openCleanupFilesDialog,
openShiftSegmentsDialog,
openDecimateDialog,
};
}

@ -613,6 +613,10 @@ const KeyboardShortcuts = memo(({
name: t('Fix incorrect duration'),
category: outputCategory,
},
decimate: {
name: t('Decimate video'),
category: outputCategory,
},
// batchFilesCategory
batchPreviousFile: {

@ -16,7 +16,7 @@ import { deleteDispositionValue, type AllFilesMeta, type Chapter, type CopyfileS
import type { LossyMode } from '../../../main';
import { UserFacingError } from '../../errors';
import mainApi from '../mainApi';
import { formatFfmpegTime, getHwaccelArgs } from '../../../common/util';
import { formatFfmpegNumber, getHwaccelArgs } from '../../../common/util';
const { join, resolve, dirname } = window.require('path');
const { writeFile, mkdir, access, constants: { W_OK } } = window.require('fs/promises');
@ -278,8 +278,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
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', formatFfmpegTime(cutFromWithAdjustment)] : [];
const cutToArgs = cuttingEnd ? ['-t', formatFfmpegTime(cutDuration)] : [];
const cutFromArgs = cuttingStart ? ['-ss', formatFfmpegNumber(cutFromWithAdjustment)] : [];
const cutToArgs = cuttingEnd ? ['-t', formatFfmpegNumber(cutDuration)] : [];
const copyFileStreamsFiltered = copyFileStreams.filter(({ streamIds }) => streamIds.length > 0);
@ -518,10 +518,10 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
'-ss', formatFfmpegTime(cutFrom), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043
'-ss', formatFfmpegNumber(cutFrom), // if we don't -ss before -i, seeking will be slow for long files, see https://github.com/mifi/lossless-cut/issues/126#issuecomment-1135451043
'-i', filePath,
'-ss', '0', // If we don't do this, the output seems to start with an empty black after merging with the encoded part
'-t', formatFfmpegTime(cutTo - cutFrom),
'-t', formatFfmpegNumber(cutTo - cutFrom),
...mapStreamsArgs,
@ -893,19 +893,15 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
}, [appendFfmpegCommandLog, ffmpegHwaccel, html5ifyDummy, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
// https://stackoverflow.com/questions/34118013/how-to-determine-webm-duration-using-ffprobe
const fixInvalidDuration = useCallback(async ({ fileFormat, customOutDir, onProgress }: {
fileFormat: string,
customOutDir?: string | undefined,
const fixInvalidDuration = useCallback(async ({ filePath: filePathArg, outPath, onProgress }: {
filePath: string,
outPath: string,
onProgress: (a: number) => void,
}) => {
invariant(filePath != null);
const ext = getOutFileExtension({ outFormat: fileFormat, filePath });
const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `reformatted${ext}` });
const ffmpegArgs = [
'-hide_banner',
'-i', filePath,
'-i', filePathArg,
// https://github.com/mifi/lossless-cut/issues/1415
'-map_metadata', '0',
@ -920,10 +916,36 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
const result = await runFfmpegWithProgress({ ffmpegArgs, onProgress });
logStdoutStderr(result);
await transferTimestamps({ inPath: filePath, outPath, duration: undefined, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart });
return outPath;
}, [appendFfmpegCommandLog]);
// https://github.com/mifi/lossless-cut/issues/2111
const decimate = useCallback(async ({ filePath: filePathArg, outPath, n, fps }: {
n: number,
fps: number,
filePath: string,
outPath: string,
}) => {
const ffmpegArgs = [
'-hide_banner',
// '-discard', 'nokey', // doesn't seem to work with hevc, so use noise=drop=not(key) instead
// https://chatgpt.com/share/6a1c3be1-1064-83ec-b5c1-fa91ddf3cde8
'-i', filePathArg,
'-map', 'v:0',
'-c', 'copy',
'-bsf:v', `noise=drop=not(key),noise=drop='mod(n\\,${formatFfmpegNumber(n)})',setts=ts='N/${formatFfmpegNumber(fps)}/TB_OUT'`,
'-an',
'-ignore_unknown',
'-y', outPath,
];
appendFfmpegCommandLog(ffmpegArgs);
const result = await runFfmpeg(ffmpegArgs);
logStdoutStderr(result);
return outPath;
}, [appendFfmpegCommandLog, filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]);
}, [appendFfmpegCommandLog]);
function getPreferredCodecFormat(stream: LiteFFprobeStream) {
const map = {
@ -1069,7 +1091,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
}, [extractAttachmentStreams, extractNonAttachmentStreams, filePath]);
return {
cutMultiple, concatFiles, html5ify, html5ifyDummy, fixInvalidDuration, concatCutSegments, extractStreams, tryDeleteFiles,
cutMultiple, concatFiles, html5ify, html5ifyDummy, fixInvalidDuration, decimate, concatCutSegments, extractStreams, tryDeleteFiles,
};
}

Loading…
Cancel
Save