From fdbccfa54192a7b2149603b9f685a196bdc904b0 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 17 Sep 2023 19:16:42 +0200 Subject: [PATCH] implement speedup/slowdown closes #1712 --- README.md | 1 + issues.md | 2 -- src/App.jsx | 15 ++++++++++++--- src/BottomBar.jsx | 15 +++++++++++++-- src/dialogs/index.jsx | 30 ++++++++++++++++++++++++++++++ src/hooks/useFfmpegOperations.js | 8 ++++++-- 6 files changed, 62 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index bb5123e0..17b60b59 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic - Customizable keyboard hotkeys - Black scene detection, silent audio detection, and scene change detection - Divide timeline into segments with length L or into N segments or even randomized segments! +- Speed up / slow down video or audio file ([changing FPS](https://github.com/mifi/lossless-cut/issues/1712)) - [Basic CLI support](cli.md) ## Example lossless use cases diff --git a/issues.md b/issues.md index 4e3a9176..a07bd3d8 100644 --- a/issues.md +++ b/issues.md @@ -2,8 +2,6 @@ - **Can LosslessCut crop, resize, stretch, mirror, overlay text/images, watermark, blur, redact, re-encode, create GIF, slideshow, burn subtitles, color grading, fade/transition between video clips, fade/combine/mix/merge audio tracks or change audio volume?** - No, these are all lossy operations (meaning you *have* to re-encode the file), but in the future I may start to implement such features. [See this issue for more information.](https://github.com/mifi/lossless-cut/issues/372) -- Can i speed-up/slow-down video? - - Not yet, but see this issue: [#1712](https://github.com/mifi/lossless-cut/issues/1712) - Can LosslessCut be batched/automated using a CLI or API? - While it was never designed for advanced batching/automation, it does have a [basic CLI](./cli.md), and there are a few feature requests regarding this: [#980](https://github.com/mifi/lossless-cut/issues/980) [#868](https://github.com/mifi/lossless-cut/issues/868). - Is there a keyboard shortcut to do X? diff --git a/src/App.jsx b/src/App.jsx index 648f610b..39851fd9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -149,6 +149,7 @@ const App = memo(() => { const [exportConfirmVisible, setExportConfirmVisible] = useState(false); const [cacheBuster, setCacheBuster] = useState(0); const [customMergedOutFileName, setMergedOutFileName] = useState(); + const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1); const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); @@ -203,6 +204,11 @@ const App = memo(() => { const videoRef = useRef(); + const setOutputPlaybackRate = useCallback((v) => { + setOutputPlaybackRateState(v); + if (videoRef.current) videoRef.current.playbackRate = v; + }, []); + const isFileOpened = !!filePath; const onOutputFormatUserChange = useCallback((newFormat) => { @@ -733,6 +739,7 @@ const App = memo(() => { setHideCanvasPreview(false); setExportConfirmVisible(false); setMergedOutFileName(); + setOutputPlaybackRateState(1); cancelRenderThumbnails(); }, [cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setDeselectedSegmentIds, cancelRenderThumbnails]); @@ -751,7 +758,7 @@ const App = memo(() => { const { concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration, - } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput }); + } = useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate }); const html5ifyAndLoad = useCallback(async (cod, fp, speed, hv, ha) => { const usesDummyVideo = ['fastest-audio', 'fastest-audio-remux', 'fastest'].includes(speed); @@ -855,12 +862,12 @@ const App = memo(() => { if (Math.abs(commandedTimeRef.current - video.currentTime) > 1) video.currentTime = commandedTimeRef.current; - if (resetPlaybackRate) video.playbackRate = 1; + if (resetPlaybackRate) video.playbackRate = outputPlaybackRate; video.play().catch((err) => { showPlaybackFailedMessage(); console.error(err); }); - }, [filePath, playing]); + }, [filePath, outputPlaybackRate, playing]); const togglePlay = useCallback(({ resetPlaybackRate, playbackMode } = {}) => { playbackModeRef.current = undefined; @@ -2469,6 +2476,8 @@ const App = memo(() => { isFileOpened={isFileOpened} darkMode={darkMode} setDarkMode={setDarkMode} + outputPlaybackRate={outputPlaybackRate} + setOutputPlaybackRate={setOutputPlaybackRate} /> diff --git a/src/BottomBar.jsx b/src/BottomBar.jsx index fb010d46..b7a0227b 100644 --- a/src/BottomBar.jsx +++ b/src/BottomBar.jsx @@ -2,7 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; import { MdRotate90DegreesCcw } from 'react-icons/md'; import { useTranslation } from 'react-i18next'; -import { IoIosCamera, IoMdKey } from 'react-icons/io'; +import { IoIosCamera, IoMdKey, IoMdSpeedometer } from 'react-icons/io'; import { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey, FaSun } from 'react-icons/fa'; import { GiSoundWaves } from 'react-icons/gi'; // import useTraceUpdate from 'use-trace-update'; @@ -22,6 +22,7 @@ import { getSegColor as getSegColorRaw } from './util/colors'; import { useSegColors } from './contexts'; import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration'; import useUserSettings from './hooks/useUserSettings'; +import { askForPlaybackRate } from './dialogs'; const { clipboard } = window.require('electron'); @@ -144,6 +145,7 @@ const BottomBar = memo(({ keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, detectedFps, isFileOpened, selectedSegments, darkMode, setDarkMode, toggleEnableThumbnails, toggleWaveformMode, waveformMode, showThumbnails, + outputPlaybackRate, setOutputPlaybackRate, }) => { const { t } = useTranslation(); const { getSegColor } = useSegColors(); @@ -192,6 +194,11 @@ const BottomBar = memo(({ checkAppPath(); }, []); + const handleChangePlaybackRateClick = useCallback(async () => { + const newRate = await askForPlaybackRate({ detectedFps, outputPlaybackRate }); + if (newRate != null) setOutputPlaybackRate(newRate); + }, [detectedFps, outputPlaybackRate, setOutputPlaybackRate]); + function renderJumpCutpointButton(direction) { const newIndex = currentSegIndexSafe + direction; const seg = cutSegments[newIndex]; @@ -383,7 +390,11 @@ const BottomBar = memo(({ ))} - {detectedFps != null &&
{detectedFps.toFixed(3)}
} + {detectedFps != null && ( +
{(detectedFps * outputPlaybackRate).toFixed(3)}
+ )} + + )} diff --git a/src/dialogs/index.jsx b/src/dialogs/index.jsx index 152aa08c..9c696f90 100644 --- a/src/dialogs/index.jsx +++ b/src/dialogs/index.jsx @@ -605,3 +605,33 @@ export async function openConcatFinishedToast({ filePath, warnings, notices }) { await openDirToast({ filePath, html, width: 800, position: 'center', timer: 30000 }); } + +export async function askForPlaybackRate({ detectedFps, outputPlaybackRate }) { + const fps = detectedFps || 1; + const currentFps = fps * outputPlaybackRate; + + function parseValue(v) { + const newFps = parseFloat(v); + if (!Number.isNaN(newFps)) { + return newFps / fps; + } + return undefined; + } + + const { value } = await Swal.fire({ + title: i18n.t('Change FPS'), + input: 'text', + inputValue: currentFps.toFixed(5), + text: i18n.t('This option lets you losslessly change the speed at which media players will play back the exported file. For example if you double the FPS, the playback speed will double (and duration will halve), however all the frames will be intact and played back (but faster). Be careful not to set it too high, as the player might not be able to keep up (playback CPU usage will increase proportionally to the speed!)'), + showCancelButton: true, + inputValidator: (v) => { + const parsed = parseValue(v); + if (parsed != null) return undefined; + return i18n.t('Please enter a valid number.'); + }, + }); + + if (!value) return undefined; + + return parseValue(value); +} diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js index 0ab667ce..51495231 100644 --- a/src/hooks/useFfmpegOperations.js +++ b/src/hooks/useFfmpegOperations.js @@ -56,13 +56,15 @@ const tryDeleteFiles = async (paths) => pMap(paths, (path) => { unlink(path).catch((err) => console.error('Failed to delete', path, err)); }, { concurrency: 5 }); -function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput }) { +function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate }) { const shouldSkipExistingFile = useCallback(async (path) => { const skip = !enableOverwriteOutput && await pathExists(path); if (skip) console.log('Not overwriting existing file', path); return skip; }, [enableOverwriteOutput]); + const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', 1 / outputPlaybackRate] : []), [outputPlaybackRate]); + const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog }) => { if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false }; @@ -279,6 +281,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea // No progress if we set loglevel warning :( // '-loglevel', 'warning', + ...getOutputPlaybackRateArgs(outputPlaybackRate), + ...inputArgs, ...mapStreamsArgs, @@ -317,7 +321,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea logStdoutStderr(result); await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart }); - }, [filePath, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); + }, [filePath, getOutputPlaybackRateArgs, outputPlaybackRate, shouldSkipExistingFile, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart]); const cutMultiple = useCallback(async ({ outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,