mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-25 11:43:17 +01:00
parent
65d674f51a
commit
fdbccfa541
@ -57,6 +57,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
|
|||||||
- Customizable keyboard hotkeys
|
- Customizable keyboard hotkeys
|
||||||
- Black scene detection, silent audio detection, and scene change detection
|
- 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!
|
- 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)
|
- [Basic CLI support](cli.md)
|
||||||
|
|
||||||
## Example lossless use cases
|
## Example lossless use cases
|
||||||
|
@ -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?**
|
- **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)
|
- 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?
|
- 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).
|
- 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?
|
- Is there a keyboard shortcut to do X?
|
||||||
|
15
src/App.jsx
15
src/App.jsx
@ -149,6 +149,7 @@ const App = memo(() => {
|
|||||||
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
||||||
const [cacheBuster, setCacheBuster] = useState(0);
|
const [cacheBuster, setCacheBuster] = useState(0);
|
||||||
const [customMergedOutFileName, setMergedOutFileName] = useState();
|
const [customMergedOutFileName, setMergedOutFileName] = useState();
|
||||||
|
const [outputPlaybackRate, setOutputPlaybackRateState] = useState(1);
|
||||||
|
|
||||||
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
||||||
|
|
||||||
@ -203,6 +204,11 @@ const App = memo(() => {
|
|||||||
|
|
||||||
const videoRef = useRef();
|
const videoRef = useRef();
|
||||||
|
|
||||||
|
const setOutputPlaybackRate = useCallback((v) => {
|
||||||
|
setOutputPlaybackRateState(v);
|
||||||
|
if (videoRef.current) videoRef.current.playbackRate = v;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const isFileOpened = !!filePath;
|
const isFileOpened = !!filePath;
|
||||||
|
|
||||||
const onOutputFormatUserChange = useCallback((newFormat) => {
|
const onOutputFormatUserChange = useCallback((newFormat) => {
|
||||||
@ -733,6 +739,7 @@ const App = memo(() => {
|
|||||||
setHideCanvasPreview(false);
|
setHideCanvasPreview(false);
|
||||||
setExportConfirmVisible(false);
|
setExportConfirmVisible(false);
|
||||||
setMergedOutFileName();
|
setMergedOutFileName();
|
||||||
|
setOutputPlaybackRateState(1);
|
||||||
|
|
||||||
cancelRenderThumbnails();
|
cancelRenderThumbnails();
|
||||||
}, [cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setDeselectedSegmentIds, cancelRenderThumbnails]);
|
}, [cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setDeselectedSegmentIds, cancelRenderThumbnails]);
|
||||||
@ -751,7 +758,7 @@ const App = memo(() => {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
concatFiles, html5ifyDummy, cutMultiple, autoConcatCutSegments, html5ify, fixInvalidDuration,
|
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 html5ifyAndLoad = useCallback(async (cod, fp, speed, hv, ha) => {
|
||||||
const usesDummyVideo = ['fastest-audio', 'fastest-audio-remux', 'fastest'].includes(speed);
|
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 (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) => {
|
video.play().catch((err) => {
|
||||||
showPlaybackFailedMessage();
|
showPlaybackFailedMessage();
|
||||||
console.error(err);
|
console.error(err);
|
||||||
});
|
});
|
||||||
}, [filePath, playing]);
|
}, [filePath, outputPlaybackRate, playing]);
|
||||||
|
|
||||||
const togglePlay = useCallback(({ resetPlaybackRate, playbackMode } = {}) => {
|
const togglePlay = useCallback(({ resetPlaybackRate, playbackMode } = {}) => {
|
||||||
playbackModeRef.current = undefined;
|
playbackModeRef.current = undefined;
|
||||||
@ -2469,6 +2476,8 @@ const App = memo(() => {
|
|||||||
isFileOpened={isFileOpened}
|
isFileOpened={isFileOpened}
|
||||||
darkMode={darkMode}
|
darkMode={darkMode}
|
||||||
setDarkMode={setDarkMode}
|
setDarkMode={setDarkMode}
|
||||||
|
outputPlaybackRate={outputPlaybackRate}
|
||||||
|
setOutputPlaybackRate={setOutputPlaybackRate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
import { MdRotate90DegreesCcw } from 'react-icons/md';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { FaYinYang, FaTrashAlt, FaStepBackward, FaStepForward, FaCaretLeft, FaCaretRight, FaPause, FaPlay, FaImages, FaKey, FaSun } from 'react-icons/fa';
|
||||||
import { GiSoundWaves } from 'react-icons/gi';
|
import { GiSoundWaves } from 'react-icons/gi';
|
||||||
// import useTraceUpdate from 'use-trace-update';
|
// import useTraceUpdate from 'use-trace-update';
|
||||||
@ -22,6 +22,7 @@ import { getSegColor as getSegColorRaw } from './util/colors';
|
|||||||
import { useSegColors } from './contexts';
|
import { useSegColors } from './contexts';
|
||||||
import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration';
|
import { formatDuration, parseDuration, isExactDurationMatch } from './util/duration';
|
||||||
import useUserSettings from './hooks/useUserSettings';
|
import useUserSettings from './hooks/useUserSettings';
|
||||||
|
import { askForPlaybackRate } from './dialogs';
|
||||||
|
|
||||||
const { clipboard } = window.require('electron');
|
const { clipboard } = window.require('electron');
|
||||||
|
|
||||||
@ -144,6 +145,7 @@ const BottomBar = memo(({
|
|||||||
keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, detectedFps, isFileOpened, selectedSegments,
|
keyframesEnabled, toggleKeyframesEnabled, seekClosestKeyframe, detectedFps, isFileOpened, selectedSegments,
|
||||||
darkMode, setDarkMode,
|
darkMode, setDarkMode,
|
||||||
toggleEnableThumbnails, toggleWaveformMode, waveformMode, showThumbnails,
|
toggleEnableThumbnails, toggleWaveformMode, waveformMode, showThumbnails,
|
||||||
|
outputPlaybackRate, setOutputPlaybackRate,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getSegColor } = useSegColors();
|
const { getSegColor } = useSegColors();
|
||||||
@ -192,6 +194,11 @@ const BottomBar = memo(({
|
|||||||
checkAppPath();
|
checkAppPath();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleChangePlaybackRateClick = useCallback(async () => {
|
||||||
|
const newRate = await askForPlaybackRate({ detectedFps, outputPlaybackRate });
|
||||||
|
if (newRate != null) setOutputPlaybackRate(newRate);
|
||||||
|
}, [detectedFps, outputPlaybackRate, setOutputPlaybackRate]);
|
||||||
|
|
||||||
function renderJumpCutpointButton(direction) {
|
function renderJumpCutpointButton(direction) {
|
||||||
const newIndex = currentSegIndexSafe + direction;
|
const newIndex = currentSegIndexSafe + direction;
|
||||||
const seg = cutSegments[newIndex];
|
const seg = cutSegments[newIndex];
|
||||||
@ -383,7 +390,11 @@ const BottomBar = memo(({
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</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} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
@ -605,3 +605,33 @@ export async function openConcatFinishedToast({ filePath, warnings, notices }) {
|
|||||||
|
|
||||||
await openDirToast({ filePath, html, width: 800, position: 'center', timer: 30000 });
|
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);
|
||||||
|
}
|
||||||
|
@ -56,13 +56,15 @@ const tryDeleteFiles = async (paths) => pMap(paths, (path) => {
|
|||||||
unlink(path).catch((err) => console.error('Failed to delete', path, err));
|
unlink(path).catch((err) => console.error('Failed to delete', path, err));
|
||||||
}, { concurrency: 5 });
|
}, { concurrency: 5 });
|
||||||
|
|
||||||
function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput }) {
|
function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate }) {
|
||||||
const shouldSkipExistingFile = useCallback(async (path) => {
|
const shouldSkipExistingFile = useCallback(async (path) => {
|
||||||
const skip = !enableOverwriteOutput && await pathExists(path);
|
const skip = !enableOverwriteOutput && await pathExists(path);
|
||||||
if (skip) console.log('Not overwriting existing file', path);
|
if (skip) console.log('Not overwriting existing file', path);
|
||||||
return skip;
|
return skip;
|
||||||
}, [enableOverwriteOutput]);
|
}, [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 }) => {
|
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 };
|
if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false };
|
||||||
|
|
||||||
@ -279,6 +281,8 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
// No progress if we set loglevel warning :(
|
// No progress if we set loglevel warning :(
|
||||||
// '-loglevel', 'warning',
|
// '-loglevel', 'warning',
|
||||||
|
|
||||||
|
...getOutputPlaybackRateArgs(outputPlaybackRate),
|
||||||
|
|
||||||
...inputArgs,
|
...inputArgs,
|
||||||
|
|
||||||
...mapStreamsArgs,
|
...mapStreamsArgs,
|
||||||
@ -317,7 +321,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
logStdoutStderr(result);
|
logStdoutStderr(result);
|
||||||
|
|
||||||
await transferTimestamps({ inPath: filePath, outPath, cutFrom, cutTo, treatInputFileModifiedTimeAsStart, duration: isDurationValid(videoDuration) ? videoDuration : undefined, treatOutputFileModifiedTimeAsStart });
|
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 ({
|
const cutMultiple = useCallback(async ({
|
||||||
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
outputDir, customOutDir, segments, outSegFileNames, videoDuration, rotation, detectedFps,
|
||||||
|
Loading…
Reference in New Issue
Block a user