1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

implement speedup/slowdown

closes #1712
This commit is contained in:
Mikael Finstad 2023-09-17 19:16:42 +02:00
parent 65d674f51a
commit fdbccfa541
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
6 changed files with 62 additions and 9 deletions

View File

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

View File

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

View File

@ -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}
/>
</div>

View File

@ -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(({
))}
</Select>
{detectedFps != null && <div title={t('Video FPS')} style={{ color: 'var(--gray11)', fontSize: '.7em', marginLeft: 6 }}>{detectedFps.toFixed(3)}</div>}
{detectedFps != null && (
<div title={t('Video FPS')} role="button" onClick={handleChangePlaybackRateClick} style={{ color: 'var(--gray11)', fontSize: '.7em', marginLeft: 6 }}>{(detectedFps * outputPlaybackRate).toFixed(3)}</div>
)}
<IoMdSpeedometer title={t('Change FPS')} style={{ padding: '0 .2em', fontSize: '1.3em' }} role="button" onClick={handleChangePlaybackRateClick} />
</>
)}

View File

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

View File

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