implement smart cut #126

also improve concat ffmpeg command logging - closes #954
pull/901/head
Mikael Finstad 4 years ago
parent 1b4262d951
commit 11e1f81726
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -23,6 +23,7 @@ The main feature is lossless trimming and cutting of video and audio files, whic
## Features
- Lossless cutting of most video and audio formats
- [Smart cut](https://github.com/mifi/lossless-cut/issues/126) (experimental)
- Losslessly cut out parts of video/audio (for cutting away commercials etc.)
- Losslessly rearrange the order of video/audio segments
- Lossless merge/concatenation of arbitrary files (with identical codecs parameters, e.g. from the same camera)
@ -152,6 +153,7 @@ Unsupported files can still be converted to a supported format/codec from the `F
- **Cutting times are not accurate!** Start cut time will be "rounded" to the nearest **previous** keyframe. This means that you often have **move the start cut time to few frames after** the desired keyframe.
- Lossless cutting is not an exact science. For some codecs, it just works. For others, you may need to trial and error depending on the codec, keyframes etc to get the best cut. See [#330](https://github.com/mifi/lossless-cut/issues/330)
- Your mileage may vary when it comes to `Keyframe cut` vs `Normal cut`. You may need to try both, depending on the video. [ffmpeg](https://trac.ffmpeg.org/wiki/Seeking) also has documentation about these two seek/cut modes. `Keyframe cut` means `-ss` *before* `-i` and `Normal cut` means `-ss` *after* `-i`.
- You may try to enable the new "Smart cut" mode. However it is very experimental and may not work for most files.
- When exporting you may lose some proprietary data tracks (like `tmcd`, `fdsc` and `gpmd` added by GoPro). These can however be losslessly exported to separate files.
- EXIF/metadata can be preserved (see Export Options dialog), but it doesn't always output compliant files, so use it carefully.
- Some codecs are not supported natively. There is partial support with low quality playback and no audio. You can convert to a supported codec from the File menu, see [#88](https://github.com/mifi/lossless-cut/issues/88), however it may take some time.

@ -75,6 +75,7 @@ const defaults = {
autoMerge: false,
autoDeleteMergedSegments: true,
segmentsToChaptersOnly: false,
enableSmartCut: false,
timecodeFormat: 'timecodeWithDecimalFraction',
invertCutSegments: false,
autoExportExtraStreams: true,

@ -195,7 +195,7 @@ const App = memo(() => {
const zoomedDuration = isDurationValid(duration) ? duration / zoom : undefined;
const {
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify, segmentsToChaptersOnly, setSegmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings,
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify, segmentsToChaptersOnly, setSegmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, setEnableSmartCut,
} = useUserPreferences();
const {
@ -696,7 +696,7 @@ const App = memo(() => {
// console.log('merge', paths);
const metadataFromPath = paths[0];
await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments });
await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setCutProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments, appendFfmpegCommandLog });
openDirToast({ icon: 'success', dirPath: outDir, text: i18n.t('Files merged!') });
} catch (err) {
if (isOutOfSpaceError(err)) {
@ -1219,6 +1219,7 @@ const App = memo(() => {
// throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })();
const outFiles = await cutMultiple({
outputDir,
customOutDir,
outFormat: fileFormat,
videoDuration: duration,
rotation: isRotationSet ? effectiveRotation : undefined,
@ -1232,12 +1233,15 @@ const App = memo(() => {
shortestFlag,
ffmpegExperimental,
preserveMovData,
preserveMetadataOnMerge,
movFastStart,
avoidNegativeTs,
customTagsByFile,
customTagsByStreamId,
dispositionByStreamId,
chapters: chaptersToAdd,
detectedFps,
enableSmartCut,
});
if (willMerge) {
@ -1258,6 +1262,7 @@ const App = memo(() => {
chapterNames,
autoDeleteMergedSegments,
preserveMetadataOnMerge,
appendFfmpegCommandLog,
});
}
@ -1294,7 +1299,7 @@ const App = memo(() => {
setWorking();
setCutProgress();
}
}, [numStreamsToCopy, setWorking, segmentsToChaptersOnly, enabledSegments, outSegTemplateOrDefault, generateOutSegFileNames, segmentsToExport, getOutSegError, cutMultiple, outputDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, willMerge, mainFileFormatData, mainStreams, exportExtraStreams, hideAllNotifications, segmentsToChapters, invertCutSegments, autoConcatCutSegments, customOutDir, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, filePath, nonCopiedExtraStreams, handleCutFailed]);
}, [numStreamsToCopy, setWorking, segmentsToChaptersOnly, enabledSegments, outSegTemplateOrDefault, generateOutSegFileNames, segmentsToExport, getOutSegError, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, detectedFps, enableSmartCut, willMerge, mainFileFormatData, mainStreams, exportExtraStreams, hideAllNotifications, segmentsToChapters, invertCutSegments, autoConcatCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, filePath, nonCopiedExtraStreams, handleCutFailed]);
const onExportPress = useCallback(async () => {
if (!filePath || workingRef.current) return;
@ -2115,8 +2120,8 @@ const App = memo(() => {
), [captureFormat, toggleCaptureFormat]);
const AutoExportToggler = useCallback(() => (
<Button intent={autoExportExtraStreams === 'extract' ? 'success' : 'danger'} iconBefore={autoExportExtraStreams === 'extract' ? ForkIcon : DisableIcon} onClick={() => setAutoExportExtraStreams(autoExportExtraStreams === 'extract' ? 'discard' : 'extract')}>
{autoExportExtraStreams === 'extract' ? i18n.t('Extract') : i18n.t('Discard')}
<Button intent={autoExportExtraStreams ? 'success' : 'danger'} iconBefore={autoExportExtraStreams ? ForkIcon : DisableIcon} onClick={() => setAutoExportExtraStreams(!autoExportExtraStreams)}>
{autoExportExtraStreams ? i18n.t('Extract') : i18n.t('Discard')}
</Button>
), [autoExportExtraStreams, setAutoExportExtraStreams]);
@ -2459,7 +2464,7 @@ const App = memo(() => {
/>
</SideSheet>
<ExportConfirm filePath={filePath} autoMerge={autoMerge} setAutoMerge={setAutoMerge} areWeCutting={areWeCutting} enabledSegments={enabledSegments} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} keyframeCut={keyframeCut} toggleKeyframeCut={toggleKeyframeCut} renderOutFmt={renderOutFmt} preserveMovData={preserveMovData} togglePreserveMovData={togglePreserveMovData} movFastStart={movFastStart} toggleMovFastStart={toggleMovFastStart} avoidNegativeTs={avoidNegativeTs} setAvoidNegativeTs={setAvoidNegativeTs} changeOutDir={changeOutDir} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} setStreamsSelectorShown={setStreamsSelectorShown} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} segmentsToChapters={segmentsToChapters} toggleSegmentsToChapters={toggleSegmentsToChapters} outFormat={fileFormat} preserveMetadataOnMerge={preserveMetadataOnMerge} togglePreserveMetadataOnMerge={togglePreserveMetadataOnMerge} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} getOutSegError={getOutSegError} autoDeleteMergedSegments={autoDeleteMergedSegments} setAutoDeleteMergedSegments={setAutoDeleteMergedSegments} safeOutputFileName={safeOutputFileName} toggleSafeOutputFileName={toggleSafeOutputFileName} segmentsToChaptersOnly={segmentsToChaptersOnly} setSegmentsToChaptersOnly={setSegmentsToChaptersOnly} />
<ExportConfirm filePath={filePath} autoMerge={autoMerge} setAutoMerge={setAutoMerge} areWeCutting={areWeCutting} enabledSegments={enabledSegments} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} keyframeCut={keyframeCut} toggleKeyframeCut={toggleKeyframeCut} renderOutFmt={renderOutFmt} preserveMovData={preserveMovData} togglePreserveMovData={togglePreserveMovData} movFastStart={movFastStart} toggleMovFastStart={toggleMovFastStart} avoidNegativeTs={avoidNegativeTs} setAvoidNegativeTs={setAvoidNegativeTs} changeOutDir={changeOutDir} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} setStreamsSelectorShown={setStreamsSelectorShown} exportConfirmEnabled={exportConfirmEnabled} toggleExportConfirmEnabled={toggleExportConfirmEnabled} segmentsToChapters={segmentsToChapters} toggleSegmentsToChapters={toggleSegmentsToChapters} outFormat={fileFormat} preserveMetadataOnMerge={preserveMetadataOnMerge} togglePreserveMetadataOnMerge={togglePreserveMetadataOnMerge} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} getOutSegError={getOutSegError} autoDeleteMergedSegments={autoDeleteMergedSegments} setAutoDeleteMergedSegments={setAutoDeleteMergedSegments} safeOutputFileName={safeOutputFileName} toggleSafeOutputFileName={toggleSafeOutputFileName} segmentsToChaptersOnly={segmentsToChaptersOnly} setSegmentsToChaptersOnly={setSegmentsToChaptersOnly} enableSmartCut={enableSmartCut} setEnableSmartCut={setEnableSmartCut} />
<HelpSheet
visible={helpVisible}

@ -45,7 +45,7 @@ const ExportConfirm = memo(({
exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, outFormat,
preserveMetadataOnMerge, togglePreserveMetadataOnMerge, outSegTemplate, setOutSegTemplate, generateOutSegFileNames,
filePath, currentSegIndexSafe, getOutSegError, autoDeleteMergedSegments, setAutoDeleteMergedSegments,
safeOutputFileName, toggleSafeOutputFileName, segmentsToChaptersOnly, setSegmentsToChaptersOnly,
safeOutputFileName, toggleSafeOutputFileName, segmentsToChaptersOnly, setSegmentsToChaptersOnly, enableSmartCut, setEnableSmartCut,
}) => {
const { t } = useTranslation();
@ -68,6 +68,10 @@ const ExportConfirm = memo(({
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('With "keyframe cut", we will cut at the nearest keyframe before the desired start cutpoint. This is recommended for most files. With "Normal cut" you may have to manually set the cutpoint a few frames before the next keyframe to achieve a precise cut') });
}, []);
const onSmartCutHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('This experimental feature will re-encode the part of the video from the cutpoint until the next keyframe in order to attempt to make a 100% accurate cut. Only works on some files. I\'ve had success with some h264 files, and only a few h265 files. See more here: {{url}}', { url: 'https://github.com/mifi/lossless-cut/issues/126' }) });
}, []);
const onTracksHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('Not all formats support all track types, and LosslessCut is unable to properly cut some track types, so you may have to sacrifice some tracks by disabling them in order to get correct result.') });
}, []);
@ -155,10 +159,18 @@ const ExportConfirm = memo(({
<ul>
{areWeCutting && (
<li>
{t('Cut mode:')} <KeyframeCutButton keyframeCut={keyframeCut} onClick={withBlur(() => toggleKeyframeCut(false))} />
<HelpIcon onClick={onKeyframeCutHelpPress} /> {!keyframeCut && <span style={warningStyle}>{t('Note: Keyframe cut is recommended for most common files')}</span>}
</li>
<>
<li>
{t('Smart cut (experimental):')} <Button height={20} onClick={() => setEnableSmartCut((v) => !v)}>{enableSmartCut ? t('Yes') : t('No')}</Button>
<HelpIcon onClick={onSmartCutHelpPress} />
</li>
{!enableSmartCut && (
<li>
{t('Cut mode:')} <KeyframeCutButton keyframeCut={keyframeCut} onClick={withBlur(() => toggleKeyframeCut(false))} />
<HelpIcon onClick={onKeyframeCutHelpPress} /> {!keyframeCut && <span style={warningStyle}>{t('Note: Keyframe cut is recommended for most common files')}</span>}
</li>
)}
</>
)}
{isMov && (
@ -173,16 +185,19 @@ const ExportConfirm = memo(({
</li>
</>
)}
<li>
{t('Shift timestamps (avoid_negative_ts)')}
<Select height={20} value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value)} style={{ marginLeft: 5 }}>
<option value="make_zero">make_zero</option>
<option value="make_non_negative">make_non_negative</option>
<option value="auto">auto</option>
<option value="disabled">disabled</option>
</Select>
<HelpIcon onClick={onAvoidNegativeTsHelpPress} />
</li>
{!enableSmartCut && (
<li>
{t('Shift timestamps (avoid_negative_ts)')}
<Select height={20} value={avoidNegativeTs} onChange={(e) => setAvoidNegativeTs(e.target.value)} style={{ marginLeft: 5 }}>
<option value="make_zero">make_zero</option>
<option value="make_non_negative">make_non_negative</option>
<option value="auto">auto</option>
<option value="disabled">disabled</option>
</Select>
<HelpIcon onClick={onAvoidNegativeTsHelpPress} />
</li>
)}
</ul>
</div>
</div>

@ -5,7 +5,7 @@ import moment from 'moment';
import i18n from 'i18next';
import Timecode from 'smpte-timecode';
import { pcmAudioCodecs } from './util/streams';
import { pcmAudioCodecs, getMapStreamsArgs } from './util/streams';
import { getOutPath, isDurationValid, getExtensionForFormat, isWindows, isMac, platform } from './util';
const execa = window.require('execa');
@ -766,3 +766,54 @@ export async function html5ify({ outPath, filePath: filePathArg, speed, hasAudio
const { stdout } = await process;
console.log(stdout);
}
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
export const getExperimentalArgs = (ffmpegExperimental) => (ffmpegExperimental ? ['-strict', 'experimental'] : []);
export const getVideoTimescaleArgs = (videoTimebase) => (videoTimebase != null ? ['-video_track_timescale', videoTimebase] : []);
// inspired by https://gist.github.com/fernandoherreradelasheras/5eca67f4200f1a7cc8281747da08496e
export async function cutEncodeSmartPart({ filePath, cutFrom, cutTo, outPath, outFormat, videoCodec, videoBitrate, videoTimebase, allFilesMeta, copyFileStreams, videoStreamIndex, ffmpegExperimental }) {
function getVideoArgs({ streamIndex, outputIndex }) {
if (streamIndex !== videoStreamIndex) return undefined;
return [
`-c:${outputIndex}`, videoCodec,
`-b:${outputIndex}`, videoBitrate,
];
}
const mapStreamsArgs = getMapStreamsArgs({
allFilesMeta,
copyFileStreams,
outFormat,
getVideoArgs,
});
const ffmpegArgs = [
'-hide_banner',
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
// cannot use -ss before -i here (will lead to issues)
'-i', filePath,
'-ss', cutFrom.toFixed(5),
'-t', (cutTo - cutFrom).toFixed(5),
...mapStreamsArgs,
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
...getVideoTimescaleArgs(videoTimebase),
...getExperimentalArgs(ffmpegExperimental),
'-f', outFormat, '-y', outPath,
];
const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs);
console.log(ffmpegCommandLine);
await execa(getFfmpegPath(), ffmpegArgs);
}

@ -3,9 +3,10 @@ import flatMap from 'lodash/flatMap';
import sum from 'lodash/sum';
import pMap from 'p-map';
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta } from '../ffmpeg';
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath } from '../util';
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs } from '../ffmpeg';
import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams';
import { getSmartCutParams } from '../smartcut';
const execa = window.require('execa');
const { join, resolve } = window.require('path');
@ -52,13 +53,16 @@ function getMatroskaFlags() {
const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
const tryDeleteFiles = async (paths) => pMap(paths, (path) => {
fs.unlink(path).catch((err) => console.error('Failed to delete', path, err));
}, { concurrency: 5 });
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const optionalTransferTimestamps = useCallback(async (...args) => {
if (enableTransferTimestamps) await transferTimestamps(...args);
}, [enableTransferTimestamps]);
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }) => {
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog }) => {
console.log('Merging files', { paths }, 'to', outPath);
const durations = await pMap(paths, getDuration, { concurrency: 1 });
@ -129,21 +133,24 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
...(ffmpegExperimental ? ['-strict', 'experimental'] : []),
...getExperimentalArgs(ffmpegExperimental),
...getVideoTimescaleArgs(videoTimebase),
...(outFormat ? ['-f', outFormat] : []),
'-y', outPath,
];
console.log('ffmpeg', ffmpegArgs.join(' '));
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
// Must add "file:" or we get "Impossible to open 'pipe:xyz.mp4'" on newer ffmpeg versions
// https://superuser.com/questions/718027/ffmpeg-concat-doesnt-work-with-absolute-path
const concatTxt = paths.map(file => `file 'file:${resolve(file).replace(/'/g, "'\\''")}'`).join('\n');
console.log(concatTxt);
const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs);
const fullCommandLine = `echo -e "${concatTxt.replace(/\n/, '\\n')}" | ${ffmpegCommandLine}`;
console.log(fullCommandLine);
appendFfmpegCommandLog(fullCommandLine);
const ffmpegPath = getFfmpegPath();
const process = execa(ffmpegPath, ffmpegArgs);
@ -155,166 +162,168 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const { stdout } = await process;
console.log(stdout);
} finally {
if (chaptersPath) await fs.unlink(chaptersPath).catch((err) => console.error('Failed to delete', chaptersPath, err));
if (chaptersPath) await tryDeleteFiles([chaptersPath]);
}
await optionalTransferTimestamps(metadataFromPath, outPath);
}, [optionalTransferTimestamps]);
const cutMultiple = useCallback(async ({
outputDir, segments, segmentsFileNames, videoDuration, rotation,
onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat,
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters,
const cutSingle = useCallback(async ({
keyframeCut: ssBeforeInput, avoidNegativeTs, copyFileStreams, cutFrom, cutTo, chaptersPath, onProgress, outPath,
videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, customTagsByStreamId, dispositionByStreamId, videoTimebase,
}) => {
async function cutSingle({ cutFrom, cutTo, chaptersPath, onProgress, outPath }) {
const cuttingStart = isCuttingStart(cutFrom);
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
console.log('Exporting from', cuttingStart ? cutFrom : 'start', 'to', cuttingEnd ? cutTo : 'end');
const ssBeforeInput = keyframeCut;
const cutDuration = cutTo - cutFrom;
// Don't cut if no need: https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cuttingStart ? ['-ss', cutFrom.toFixed(5)] : [];
const cutToArgs = cuttingEnd ? ['-t', cutDuration.toFixed(5)] : [];
const copyFileStreamsFiltered = copyFileStreams.filter(({ streamIds }) => streamIds.length > 0);
// remove -avoid_negative_ts make_zero when not cutting start (no -ss), or else some videos get blank first frame in QuickLook
const avoidNegativeTsArgs = cuttingStart && avoidNegativeTs ? ['-avoid_negative_ts', avoidNegativeTs] : [];
const inputFilesArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
const inputFilesArgsWithCuts = ssBeforeInput ? [
...cutFromArgs,
...inputFilesArgs,
...cutToArgs,
...avoidNegativeTsArgs,
] : [
...inputFilesArgs,
...cutFromArgs,
...cutToArgs,
];
const cuttingStart = isCuttingStart(cutFrom);
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
console.log('Cutting from', cuttingStart ? cutFrom : 'start', 'to', cuttingEnd ? cutTo : 'end');
const cutDuration = cutTo - cutFrom;
// Don't cut if no need: https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cuttingStart ? ['-ss', cutFrom.toFixed(5)] : [];
const cutToArgs = cuttingEnd ? ['-t', cutDuration.toFixed(5)] : [];
const copyFileStreamsFiltered = copyFileStreams.filter(({ streamIds }) => streamIds.length > 0);
// remove -avoid_negative_ts make_zero when not cutting start (no -ss), or else some videos get blank first frame in QuickLook
const avoidNegativeTsArgs = cuttingStart && avoidNegativeTs ? ['-avoid_negative_ts', avoidNegativeTs] : [];
const inputFilesArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
const inputFilesArgsWithCuts = ssBeforeInput ? [
...cutFromArgs,
...inputFilesArgs,
...cutToArgs,
...avoidNegativeTsArgs,
] : [
...inputFilesArgs,
...cutFromArgs,
...cutToArgs,
];
const inputArgs = [
...inputFilesArgsWithCuts,
...getChaptersInputArgs(chaptersPath),
];
const inputArgs = [
...inputFilesArgsWithCuts,
...getChaptersInputArgs(chaptersPath),
];
const chaptersInputIndex = copyFileStreamsFiltered.length;
const chaptersInputIndex = copyFileStreamsFiltered.length;
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
// This function tries to calculate the output stream index needed for -metadata:s:x and -disposition:x arguments
// It is based on the assumption that copyFileStreamsFiltered contains the order of the input files (and their respective streams orders) sent to ffmpeg, to hopefully calculate the same output stream index values that ffmpeg does internally.
// It also takes into account previously added files that have been removed and disabled streams.
function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) {
let streamCount = 0;
// Count copied streams of all files until this input file
const foundFile = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => {
if (path2 === inputFilePath) return true;
streamCount += streamIds.length;
return false;
});
if (!foundFile) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
// This function tries to calculate the output stream index needed for -metadata:s:x and -disposition:x arguments
// It is based on the assumption that copyFileStreamsFiltered contains the order of the input files (and their respective streams orders) sent to ffmpeg, to hopefully calculate the same output stream index values that ffmpeg does internally.
// It also takes into account previously added files that have been removed and disabled streams.
function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) {
let streamCount = 0;
// Count copied streams of all files until this input file
const foundFile = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => {
if (path2 === inputFilePath) return true;
streamCount += streamIds.length;
return false;
});
if (!foundFile) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
// Then add the index of the current stream index to the count
const copiedStreamIndex = foundFile.streamIds.indexOf(inputFileStreamIndex);
if (copiedStreamIndex === -1) return undefined; // Could happen if a tag has been edited on a stream, but the stream is disabled
return streamCount + copiedStreamIndex;
}
// Then add the index of the current stream index to the count
const copiedStreamIndex = foundFile.streamIds.indexOf(inputFileStreamIndex);
if (copiedStreamIndex === -1) return undefined; // Could happen if a tag has been edited on a stream, but the stream is disabled
return streamCount + copiedStreamIndex;
}
function lessDeepMap(root, fn) {
let ret = [];
Object.entries(root).forEach(([path, streamsMap]) => (
Object.entries(streamsMap || {}).forEach(([streamId, value]) => {
ret = [...ret, ...fn(path, streamId, value)];
})));
function lessDeepMap(root, fn) {
let ret = [];
Object.entries(root).forEach(([path, streamsMap]) => (
Object.entries(streamsMap || {}).forEach(([streamId, value]) => {
ret = [...ret, ...fn(path, streamId, value)];
})));
return ret;
}
return ret;
}
// The structure is deep! file -> stream -> key -> value Example: { 'file.mp4': { 0: { key: 'value' } } }
const deepMap = (root, fn) => lessDeepMap(root, (path, streamId, tagsMap) => {
let ret = [];
Object.entries(tagsMap || {}).forEach(([key, value]) => {
ret = [...ret, ...fn(path, streamId, key, value)];
});
return ret;
// The structure is deep! file -> stream -> key -> value Example: { 'file.mp4': { 0: { key: 'value' } } }
const deepMap = (root, fn) => lessDeepMap(root, (path, streamId, tagsMap) => {
let ret = [];
Object.entries(tagsMap || {}).forEach(([key, value]) => {
ret = [...ret, ...fn(path, streamId, key, value)];
});
return ret;
});
const customTagsArgs = [
// Main file metadata:
...flatMap(Object.entries(customTagsByFile[filePath] || []), ([key, value]) => ['-metadata', `${key}=${value}`]),
const customTagsArgs = [
// Main file metadata:
...flatMap(Object.entries(customTagsByFile[filePath] || []), ([key, value]) => ['-metadata', `${key}=${value}`]),
// Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } }
...deepMap(customTagsByStreamId, (path, streamId, tag, value) => {
const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10));
if (outputIndex == null) return [];
return [`-metadata:s:${outputIndex}`, `${tag}=${value}`];
}),
];
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
// Example: { 'file.mp4': { 0: { attached_pic: 1 } } }
const customDispositionArgs = lessDeepMap(dispositionByStreamId, (path, streamId, disposition) => {
if (disposition == null) return [];
// Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } }
...deepMap(customTagsByStreamId, (path, streamId, tag, value) => {
const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10));
if (outputIndex == null) return [];
// 0 means delete the disposition for this stream
const dispositionArg = disposition === deleteDispositionValue ? '0' : disposition;
return [`-disposition:${outputIndex}`, String(dispositionArg)];
});
return [`-metadata:s:${outputIndex}`, `${tag}=${value}`];
}),
];
const ffmpegArgs = [
'-hide_banner',
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
const mapStreamsArgs = getMapStreamsArgs({ copyFileStreams: copyFileStreamsFiltered, allFilesMeta, outFormat });
...inputArgs,
// Example: { 'file.mp4': { 0: { attached_pic: 1 } } }
const customDispositionArgs = lessDeepMap(dispositionByStreamId, (path, streamId, disposition) => {
if (disposition == null) return [];
const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10));
if (outputIndex == null) return [];
// 0 means delete the disposition for this stream
const dispositionArg = disposition === deleteDispositionValue ? '0' : disposition;
return [`-disposition:${outputIndex}`, String(dispositionArg)];
});
...mapStreamsArgs,
const ffmpegArgs = [
'-hide_banner',
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
'-map_metadata', '0',
...inputArgs,
...(chaptersPath ? ['-map_chapters', chaptersInputIndex] : []),
...mapStreamsArgs,
...(shortestFlag ? ['-shortest'] : []),
'-map_metadata', '0',
...getMovFlags({ preserveMovData, movFastStart }),
...getMatroskaFlags(),
...(chaptersPath ? ['-map_chapters', chaptersInputIndex] : []),
...customTagsArgs,
...(shortestFlag ? ['-shortest'] : []),
...customDispositionArgs,
...getMovFlags({ preserveMovData, movFastStart }),
...getMatroskaFlags(),
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
...customTagsArgs,
// https://superuser.com/questions/543589/information-about-ffmpeg-command-line-options
...(ffmpegExperimental ? ['-strict', 'experimental'] : []),
...customDispositionArgs,
...rotationArgs,
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
'-f', outFormat, '-y', outPath,
];
...getExperimentalArgs(ffmpegExperimental),
const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs);
...rotationArgs,
console.log(ffmpegCommandLine);
appendFfmpegCommandLog(ffmpegCommandLine);
...getVideoTimescaleArgs(videoTimebase),
const ffmpegPath = getFfmpegPath();
const process = execa(ffmpegPath, ffmpegArgs);
handleProgress(process, cutDuration, onProgress);
const result = await process;
console.log(result.stdout);
'-f', outFormat, '-y', outPath,
];
await optionalTransferTimestamps(filePath, outPath, cutFrom);
}
const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs);
console.log(ffmpegCommandLine);
appendFfmpegCommandLog(ffmpegCommandLine);
const ffmpegPath = getFfmpegPath();
const process = execa(ffmpegPath, ffmpegArgs);
handleProgress(process, cutDuration, onProgress);
const result = await process;
console.log(result.stdout);
await optionalTransferTimestamps(filePath, outPath, cutFrom);
}, [filePath, optionalTransferTimestamps]);
const cutMultiple = useCallback(async ({
outputDir, customOutDir, segments, segmentsFileNames, videoDuration, rotation, detectedFps,
onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat,
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters, preserveMetadataOnMerge, enableSmartCut,
}) => {
console.log('customTagsByFile', customTagsByFile);
console.log('customTagsByStreamId', customTagsByStreamId);
@ -324,30 +333,88 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
return onTotalProgress((sum(Object.values(singleProgresses)) / segments.length));
}
const outFiles = [];
const chaptersPath = await writeChaptersFfmetadata(outputDir, chapters);
try {
// eslint-disable-next-line no-restricted-syntax
for (const [i, { start: cutFrom, end: cutTo }] of segments.entries()) {
const fileName = segmentsFileNames[i];
const outPath = join(outputDir, fileName);
// This function will either call cutSingle (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 as a segment
async function maybeSmartCutSegment({ start: desiredCutFrom, end: cutTo }, i) {
const getSegmentOutPath = () => join(outputDir, segmentsFileNames[i]);
if (!enableSmartCut) {
// old fashioned way
const outPath = getSegmentOutPath();
await cutSingle({
cutFrom: desiredCutFrom, cutTo, chaptersPath, outPath, copyFileStreams, keyframeCut, avoidNegativeTs, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, customTagsByStreamId, dispositionByStreamId, onProgress: (progress) => onSingleProgress(i, progress),
});
return outPath;
}
// smart cut only supports cutting main file (no externally added files)
const { streams } = allFilesMeta[filePath];
const streamsToCopyFromMainFile = copyFileStreams.find(({ path }) => path === filePath).streamIds
.map((streamId) => streams.find((stream) => stream.index === streamId));
// eslint-disable-next-line no-await-in-loop
await cutSingle({ cutFrom, cutTo, chaptersPath, outPath, onProgress: progress => onSingleProgress(i, progress) });
const { cutFrom: smartCutFrom, needsSmartCut, videoCodec, videoBitrate, videoStreamIndex, videoTimebase } = await getSmartCutParams({ path: filePath, videoDuration, desiredCutFrom, streams: streamsToCopyFromMainFile });
outFiles.push(outPath);
console.log('Smart cut on video stream', videoStreamIndex);
const onCutProgress = (progress) => onSingleProgress(i, progress / 2);
const onConcatProgress = (progress) => onSingleProgress(i, (1 + progress) / 2);
const copyFileStreamsFiltered = [{
path: filePath,
// with smart cut, we only copy/cut *one* video stream, but *all* other streams (main file only)
streamIds: streamsToCopyFromMainFile.filter((stream) => !(stream.codec_type === 'video' && stream.index !== videoStreamIndex)).map((stream) => stream.index),
}];
const ext = getOutFileExtension({ isCustomFormatSelected: true, outFormat, filePath });
const smartCutMainPartOutPath = needsSmartCut
? getOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-copy-${i}${ext}` })
: getSegmentOutPath();
const smartCutEncodedPartOutPath = getOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` });
const smartCutSegmentsToConcat = [smartCutEncodedPartOutPath, smartCutMainPartOutPath];
// for smart cut we need to use keyframe cut here
await cutSingle({
cutFrom: smartCutFrom, cutTo, chaptersPath, outPath: smartCutMainPartOutPath, copyFileStreams: copyFileStreamsFiltered, keyframeCut: true, avoidNegativeTs: false, videoDuration, rotation, allFilesMeta, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, customTagsByFile, customTagsByStreamId, dispositionByStreamId, videoTimebase, onProgress: onCutProgress,
});
// OK, just return the single cut file (we may need smart cut in other segments though)
if (!needsSmartCut) return smartCutMainPartOutPath;
try {
const frameDuration = 1 / detectedFps;
const encodeCutTo = Math.max(desiredCutFrom + frameDuration, smartCutFrom - 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
await cutEncodeSmartPart({ filePath, cutFrom: desiredCutFrom, cutTo: encodeCutTo, outPath: smartCutEncodedPartOutPath, outFormat, videoCodec, videoBitrate, videoStreamIndex, videoTimebase, allFilesMeta, copyFileStreams: copyFileStreamsFiltered, ffmpegExperimental });
// 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 outPath = getSegmentOutPath();
await concatFiles({ paths: smartCutSegmentsToConcat, outDir: outputDir, outPath, metadataFromPath: smartCutMainPartOutPath, outFormat, includeAllStreams: true, streams: streamsAfterCut, ffmpegExperimental, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase, appendFfmpegCommandLog, onProgress: onConcatProgress });
return outPath;
} finally {
await tryDeleteFiles(smartCutSegmentsToConcat);
}
}
try {
const outFiles = await pMap(segments, maybeSmartCutSegment, { concurrency: 1 });
return outFiles;
} finally {
if (chaptersPath) await fs.unlink(chaptersPath).catch((err) => console.error('Failed to delete', chaptersPath, err));
if (chaptersPath) await tryDeleteFiles([chaptersPath]);
}
}, [filePath, optionalTransferTimestamps]);
}, [concatFiles, cutSingle, filePath]);
const autoConcatCutSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge }) => {
const autoConcatCutSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, appendFfmpegCommandLog }) => {
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath });
const outPath = getOutPath({ customOutDir, filePath, nameSuffix: `cut-merged-${new Date().getTime()}${ext}` });
const outDir = getOutDir(customOutDir, filePath);
@ -357,8 +424,8 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const metadataFromPath = segmentPaths[0];
// need to re-read streams because may have changed
const { streams } = await readFileMeta(metadataFromPath);
await concatFiles({ paths: segmentPaths, outDir, outPath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
await concatFiles({ paths: segmentPaths, outDir, outPath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, appendFfmpegCommandLog });
if (autoDeleteMergedSegments) await tryDeleteFiles(segmentPaths);
}, [concatFiles, filePath]);
const html5ify = useCallback(async ({ customOutDir, filePath: filePathArg, speed, hasAudio, hasVideo, onProgress }) => {

@ -102,6 +102,9 @@ export default () => {
useEffect(() => safeSetConfig('segmentsToChaptersOnly', segmentsToChaptersOnly), [segmentsToChaptersOnly]);
const [keyBindings, setKeyBindings] = useState(safeGetConfig('keyBindings'));
useEffect(() => safeSetConfig('keyBindings', keyBindings), [keyBindings]);
const [enableSmartCut, setEnableSmartCut] = useState(safeGetConfig('enableSmartCut'));
useEffect(() => safeSetConfig('enableSmartCut', enableSmartCut), [enableSmartCut]);
const resetKeyBindings = useCallback(() => {
configStore.reset('keyBindings');
setKeyBindings(safeGetConfig('keyBindings'));
@ -187,5 +190,7 @@ export default () => {
keyBindings,
setKeyBindings,
resetKeyBindings,
enableSmartCut,
setEnableSmartCut,
};
};

@ -0,0 +1,77 @@
import { getRealVideoStreams } from './util/streams';
import { readFrames } from './ffmpeg';
const { stat } = window.require('fs-extra');
function mapInputToOutputCodec(inputCodec) {
// if (inputCodec === 'hevc') return 'libx265';
return inputCodec;
}
// eslint-disable-next-line import/prefer-default-export
export async function getSmartCutParams({ path, videoDuration, desiredCutFrom, streams }) {
const videoStreams = getRealVideoStreams(streams);
if (videoStreams.length > 1) throw new Error('Can only smart cut video with exactly one video stream');
const videoStream = videoStreams[0];
async function readKeyframes(window) {
const frames = await readFrames({ filePath: path, aroundTime: desiredCutFrom, streamIndex: videoStream.index, window });
const keyframes = frames.filter((frame) => frame.keyframe);
if (keyframes.length < 1) throw new Error('Unable to find keyframe');
return keyframes;
}
let keyframes = await readKeyframes(10);
const keyframeAtExactTime = keyframes.find((keyframe) => Math.abs(keyframe.time - desiredCutFrom) < 0.000001);
if (keyframeAtExactTime) {
console.log('Start cut is already on exact keyframe', keyframeAtExactTime.time);
return {
cutFrom: keyframeAtExactTime.time,
videoStreamIndex: videoStream.index,
needsSmartCut: false,
};
}
const findNextKeyframe = () => keyframes.find((keyframe) => keyframe.time > desiredCutFrom); // (they are already sorted)
let nextKeyframe = findNextKeyframe();
if (!nextKeyframe) {
console.log('Cannot find any keyframe after desired start cut point, trying with larger window');
keyframes = await readKeyframes(60);
nextKeyframe = findNextKeyframe();
}
if (!nextKeyframe) throw new Error('Cannot find any keyframe after desired start cut point');
console.log('Smart cut from keyframe', { keyframe: nextKeyframe.time, desiredCutFrom });
let videoBitrate = parseInt(videoStream.bit_rate, 10);
if (Number.isNaN(videoBitrate)) {
console.warn('Unable to detect input bitrate');
const stats = await stat(path);
videoBitrate = stats.size / videoDuration;
}
const videoCodec = mapInputToOutputCodec(videoStream.codec_name);
if (videoCodec == null) throw new Error('Unable to determine codec for smart cut');
let timebase;
const timebaseMatch = videoStream.time_base && videoStream.time_base.split('/');
if (timebaseMatch) {
const timebaseParsed = parseInt(timebaseMatch[1], 10);
if (!Number.isNaN(timebaseParsed)) timebase = timebaseParsed;
}
if (timebase == null) console.warn('Unable to determine timebase', videoStream.time_base);
return {
cutFrom: nextKeyframe.time,
videoStreamIndex: videoStream.index,
needsSmartCut: true,
videoCodec,
videoBitrate: Math.floor(videoBitrate),
videoTimebase: timebase,
};
}

@ -102,45 +102,55 @@ export function getActiveDisposition(disposition) {
return existingActiveDispositionEntry[0]; // return the key
}
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false }) {
let outCodec = 'copy';
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => {} }) {
let args = [];
if (['mov', 'mp4'].includes(outFormat)) {
if (stream.codec_tag === '0x0000' && stream.codec_name === 'hevc') {
args = [...args, `-tag:${outputIndex}`, 'hvc1'];
}
// mp4/mov only supports mov_text, so convert it https://stackoverflow.com/a/17584272/6519037
// https://github.com/mifi/lossless-cut/issues/418
if (stream.codec_type === 'subtitle' && stream.codec_name !== 'mov_text') {
outCodec = 'mov_text';
}
function addCodecArgs(codec) {
args = [...args, `-c:${outputIndex}`, codec];
}
if (outFormat === 'matroska') {
// matroska doesn't support mov_text, so convert it to SRT (popular codec)
if (stream.codec_type === 'subtitle') {
// mp4/mov only supports mov_text, so convert it https://stackoverflow.com/a/17584272/6519037
// https://github.com/mifi/lossless-cut/issues/418
// https://www.reddit.com/r/PleX/comments/bcfvev/can_someone_eli5_subtitles/
if (stream.codec_type === 'subtitle' && stream.codec_name === 'mov_text') {
outCodec = 'srt';
if (['mov', 'mp4'].includes(outFormat) && stream.codec_name !== 'mov_text') {
addCodecArgs('mov_text');
} else if (outFormat === 'matroska' && stream.codec_name === 'mov_text') {
// matroska doesn't support mov_text, so convert it to SRT (popular codec)
// https://github.com/mifi/lossless-cut/issues/418
// https://www.reddit.com/r/PleX/comments/bcfvev/can_someone_eli5_subtitles/
addCodecArgs('srt');
} else if (outFormat === 'webm' && stream.codec_name === 'mov_text') {
// Only WebVTT subtitles are supported for WebM.
addCodecArgs('webvtt');
} else {
addCodecArgs('copy');
}
}
if (outFormat === 'webm') {
// Only WebVTT subtitles are supported for WebM.
if (stream.codec_type === 'subtitle' && stream.codec_name === 'mov_text') {
outCodec = 'webvtt';
} else if (stream.codec_type === 'audio') {
// pcm_bluray should only ever be put in Blu-ray-style m2ts files, Matroska has no format mapping for it anyway.
// Use normal PCM (ie. pcm_s16le or pcm_s24le depending on bitdepth).
// https://forum.doom9.org/showthread.php?t=174718
// https://github.com/mifi/lossless-cut/issues/476
// ffmpeg cannot encode pcm_bluray
if (outFormat !== 'mpegts' && stream.codec_name === 'pcm_bluray') {
addCodecArgs('pcm_s24le');
} else {
addCodecArgs('copy');
}
} else if (stream.codec_type === 'video') {
const videoArgs = getVideoArgs({ streamIndex: stream.index, outputIndex });
if (videoArgs) {
args = [...videoArgs];
} else {
addCodecArgs('copy');
}
}
// pcm_bluray should only ever be put in Blu-ray-style m2ts files, Matroska has no format mapping for it anyway.
// Use normal PCM (ie. pcm_s16le or pcm_s24le depending on bitdepth).
// https://forum.doom9.org/showthread.php?t=174718
// https://github.com/mifi/lossless-cut/issues/476
// ffmpeg cannot encode pcm_bluray
if (outFormat !== 'mpegts' && stream.codec_type === 'audio' && stream.codec_name === 'pcm_bluray') {
outCodec = 'pcm_s24le';
if (['mov', 'mp4'].includes(outFormat)) {
if (['0x0000', '0x31637668'].includes(stream.codec_tag) && stream.codec_name === 'hevc') {
args = [...args, `-tag:${outputIndex}`, 'hvc1'];
}
}
} else { // other stream types
addCodecArgs('copy');
}
// when concat'ing, disposition doesn't seem to get automatically transferred by ffmpeg, so we must do it manually
@ -151,14 +161,14 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
}
}
args = [...args, `-c:${outputIndex}`, outCodec];
args = [...args];
return args;
}
export function getMapStreamsArgs({ outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition }) {
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }) {
let args = [];
let outputIndex = 0;
let outputIndex = startIndex;
copyFileStreams.forEach(({ streamIds, path }, fileIndex) => {
streamIds.forEach((streamId) => {
@ -167,7 +177,7 @@ export function getMapStreamsArgs({ outFormat, allFilesMeta, copyFileStreams, ma
args = [
...args,
'-map', `${fileIndex}:${streamId}`,
...getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition }),
...getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition, getVideoArgs }),
];
outputIndex += 1;
});

@ -27,7 +27,7 @@ test('getMapStreamsArgs', () => {
'-map', '0:0', '-c:0', 'copy',
'-map', '0:1', '-c:1', 'copy',
'-map', '0:2', '-c:2', 'copy',
'-map', '0:3', '-tag:3', 'hvc1', '-c:3', 'copy',
'-map', '0:3', '-c:3', 'copy', '-tag:3', 'hvc1',
'-map', '0:4', '-c:4', 'copy',
'-map', '0:5', '-c:5', 'copy',
'-map', '0:6', '-c:6', 'copy',
@ -63,8 +63,43 @@ test('getMapStreamsArgs, disposition', () => {
manuallyCopyDisposition: true,
})).toEqual([
'-map', '0:0',
'-c:0', 'copy',
'-disposition:0', 'attached_pic',
]);
});
test('getMapStreamsArgs, smart cut', () => {
const path = '/path/file.mp4';
const outFormat = 'mp4';
expect(getMapStreamsArgs({
allFilesMeta: { [path]: { streams: streams1 } },
copyFileStreams: [{ path, streamIds: [1, 2, 4, 5, 6, 7] }], // only 1 video stream and the rest
outFormat,
manuallyCopyDisposition: true,
getVideoArgs: ({ streamIndex, outputIndex }) => {
if (streamIndex === 2) {
return [
`-c:${outputIndex}`, 'h264',
`-b:${outputIndex}`, 123456789,
];
}
return undefined;
},
})).toEqual([
'-map', '0:1',
'-c:0', 'copy',
'-map', '0:2',
'-c:1', 'h264',
'-b:1', 123456789,
'-map', '0:4',
'-c:2', 'copy',
'-map', '0:5',
'-c:3', 'copy',
'-map', '0:6',
'-c:4', 'copy',
'-map', '0:7',
'-c:5', 'mov_text',
]);
});

Loading…
Cancel
Save