mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-26 04:02:51 +01:00
Implement setting for preserve file timestamps
Closes #611 Pull all ffmpeg operatins that transfer timestamps into its own react hook
This commit is contained in:
parent
5704713a06
commit
e72d0a6953
@ -36,6 +36,7 @@ const defaults = {
|
||||
outSegTemplate: undefined,
|
||||
keyboardSeekAccFactor: 1.03,
|
||||
keyboardNormalSeekSpeed: 1,
|
||||
enableTransferTimestamps: true,
|
||||
};
|
||||
|
||||
// For portable app: https://github.com/mifi/lossless-cut/issues/645
|
||||
|
41
src/App.jsx
41
src/App.jsx
@ -20,6 +20,7 @@ import isEqual from 'lodash/isEqual';
|
||||
|
||||
import useTimelineScroll from './hooks/useTimelineScroll';
|
||||
import useUserPreferences from './hooks/useUserPreferences';
|
||||
import useFfmpegOperations from './hooks/useFfmpegOperations';
|
||||
import NoFileLoaded from './NoFileLoaded';
|
||||
import Canvas from './Canvas';
|
||||
import TopMenu from './TopMenu';
|
||||
@ -41,10 +42,10 @@ import allOutFormats from './outFormats';
|
||||
import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame';
|
||||
import {
|
||||
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
|
||||
getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails,
|
||||
readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams,
|
||||
findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
|
||||
fixInvalidDuration, getDuration, getTimecodeFromStreams, createChaptersFromSegments,
|
||||
getDefaultOutFormat, getFormatData, renderThumbnails as ffmpegRenderThumbnails,
|
||||
readFrames, renderWaveformPng, extractStreams, getAllStreams,
|
||||
findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
|
||||
getDuration, getTimecodeFromStreams, createChaptersFromSegments,
|
||||
} from './ffmpeg';
|
||||
import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, saveCsvHuman } from './edlStore';
|
||||
import {
|
||||
@ -143,9 +144,13 @@ const App = memo(() => {
|
||||
const isCustomFormatSelected = fileFormat !== detectedFileFormat;
|
||||
|
||||
const {
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeShowFrames, setTimecodeShowFrames, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, muted, setMuted, 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,
|
||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeShowFrames, setTimecodeShowFrames, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, muted, setMuted, 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,
|
||||
} = useUserPreferences();
|
||||
|
||||
const {
|
||||
mergeFiles: ffmpegMergeFiles, html5ifyDummy, cutMultiple, autoMergeSegments, html5ify: ffmpegHtml5ify, fixInvalidDuration,
|
||||
} = useFfmpegOperations({ filePath, enableTransferTimestamps });
|
||||
|
||||
const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate;
|
||||
|
||||
useEffect(() => {
|
||||
@ -598,7 +603,7 @@ const App = memo(() => {
|
||||
setWorking();
|
||||
setCutProgress();
|
||||
}
|
||||
}, [assureOutDirAccess, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, customOutDir]);
|
||||
}, [assureOutDirAccess, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, customOutDir, ffmpegMergeFiles]);
|
||||
|
||||
const toggleCaptureFormat = useCallback(() => setCaptureFormat(f => (f === 'png' ? 'jpeg' : 'png')), [setCaptureFormat]);
|
||||
|
||||
@ -817,14 +822,14 @@ const App = memo(() => {
|
||||
const html5ifiedDummyPathDummy = getOutPath(cod, fp, 'html5ified-dummy.mkv');
|
||||
try {
|
||||
setCutProgress(0);
|
||||
await html5ifyDummy(fp, html5ifiedDummyPathDummy, setCutProgress);
|
||||
await html5ifyDummy({ filePath: fp, outPath: html5ifiedDummyPathDummy, onProgress: setCutProgress });
|
||||
} finally {
|
||||
setCutProgress();
|
||||
}
|
||||
setDummyVideoPath(html5ifiedDummyPathDummy);
|
||||
setHtml5FriendlyPath();
|
||||
showUnsupportedFileMessage();
|
||||
}, [showUnsupportedFileMessage]);
|
||||
}, [html5ifyDummy, showUnsupportedFileMessage]);
|
||||
|
||||
const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu'));
|
||||
|
||||
@ -1003,7 +1008,6 @@ const App = memo(() => {
|
||||
// throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })();
|
||||
const outFiles = await cutMultiple({
|
||||
outputDir,
|
||||
filePath,
|
||||
outFormat: fileFormat,
|
||||
videoDuration: duration,
|
||||
rotation: isRotationSet ? effectiveRotation : undefined,
|
||||
@ -1030,7 +1034,6 @@ const App = memo(() => {
|
||||
|
||||
await autoMergeSegments({
|
||||
customOutDir,
|
||||
sourceFile: filePath,
|
||||
outFormat: fileFormat,
|
||||
isCustomFormatSelected,
|
||||
segmentPaths: outFiles,
|
||||
@ -1076,7 +1079,7 @@ const App = memo(() => {
|
||||
setWorking();
|
||||
setCutProgress();
|
||||
}
|
||||
}, [working, numStreamsToCopy, outSegments, currentSegIndexSafe, outSegTemplateOrDefault, generateOutSegFileNames, customOutDir, filePath, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, autoMerge, exportExtraStreams, fileFormatData, mainStreams, hideAllNotifications, outputDir, segmentsToChapters, invertCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, nonCopiedExtraStreams, handleCutFailed, isOutSegFileNamesValid]);
|
||||
}, [working, numStreamsToCopy, outSegments, currentSegIndexSafe, outSegTemplateOrDefault, generateOutSegFileNames, customOutDir, filePath, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, autoMerge, exportExtraStreams, fileFormatData, mainStreams, hideAllNotifications, outputDir, segmentsToChapters, invertCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, nonCopiedExtraStreams, handleCutFailed, isOutSegFileNamesValid, cutMultiple, autoMergeSegments]);
|
||||
|
||||
const onExportPress = useCallback(async () => {
|
||||
if (working || !filePath) return;
|
||||
@ -1103,15 +1106,15 @@ const App = memo(() => {
|
||||
const currentTime = currentTimeRef.current;
|
||||
const video = videoRef.current;
|
||||
const outPath = mustCaptureFfmpeg
|
||||
? await captureFrameFfmpeg({ customOutDir, filePath, currentTime, captureFormat })
|
||||
: await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video });
|
||||
? await captureFrameFfmpeg({ customOutDir, filePath, currentTime, captureFormat, enableTransferTimestamps })
|
||||
: await captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps });
|
||||
|
||||
openDirToast({ dirPath: outputDir, text: `${i18n.t('Screenshot captured to:')} ${outPath}` });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
errorToast(i18n.t('Failed to capture frame'));
|
||||
}
|
||||
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath, outputDir]);
|
||||
}, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath, outputDir, enableTransferTimestamps]);
|
||||
|
||||
const changePlaybackRate = useCallback((dir) => {
|
||||
if (canvasPlayerEnabled) {
|
||||
@ -1576,7 +1579,7 @@ const App = memo(() => {
|
||||
setCutProgress();
|
||||
}
|
||||
return path;
|
||||
}, [getHtml5ifiedPath]);
|
||||
}, [ffmpegHtml5ify, getHtml5ifiedPath]);
|
||||
|
||||
const html5ifyAndLoad = useCallback(async (speed) => {
|
||||
if (speed === 'fastest-audio') {
|
||||
@ -1774,7 +1777,7 @@ const App = memo(() => {
|
||||
async function fixInvalidDuration2() {
|
||||
try {
|
||||
setWorking(i18n.t('Fixing file duration'));
|
||||
const path = await fixInvalidDuration({ filePath, fileFormat, customOutDir });
|
||||
const path = await fixInvalidDuration({ fileFormat, customOutDir });
|
||||
load({ filePath: path, customOutDir });
|
||||
toast.fire({ icon: 'info', text: i18n.t('Duration has been fixed') });
|
||||
} catch (err) {
|
||||
@ -1835,7 +1838,7 @@ const App = memo(() => {
|
||||
mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, html5ifyCurrentFile,
|
||||
createDummyVideo, extractAllStreams, userOpenFiles, openSendReportDialogWithState,
|
||||
loadEdlFile, cutSegments, edlFilePath, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal,
|
||||
loadCutSegments, duration, checkFileOpened, load, fileFormat, reorderSegsByStartTime, closeFile, clearSegments,
|
||||
loadCutSegments, duration, checkFileOpened, load, fileFormat, reorderSegsByStartTime, closeFile, clearSegments, fixInvalidDuration,
|
||||
]);
|
||||
|
||||
async function showAddStreamSourceDialog() {
|
||||
@ -1937,12 +1940,14 @@ const App = memo(() => {
|
||||
setAutoLoadTimecode={setAutoLoadTimecode}
|
||||
autoDeleteMergedSegments={autoDeleteMergedSegments}
|
||||
setAutoDeleteMergedSegments={setAutoDeleteMergedSegments}
|
||||
enableTransferTimestamps={enableTransferTimestamps}
|
||||
setEnableTransferTimestamps={setEnableTransferTimestamps}
|
||||
|
||||
AutoExportToggler={AutoExportToggler}
|
||||
renderCaptureFormatButton={renderCaptureFormatButton}
|
||||
onTunerRequested={onTunerRequested}
|
||||
/>
|
||||
), [changeOutDir, customOutDir, autoMerge, setAutoMerge, keyframeCut, setKeyframeCut, invertCutSegments, setInvertCutSegments, autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, ffmpegExperimental, setFfmpegExperimental, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, AutoExportToggler, renderCaptureFormatButton, onTunerRequested]);
|
||||
), [changeOutDir, customOutDir, autoMerge, setAutoMerge, keyframeCut, setKeyframeCut, invertCutSegments, setInvertCutSegments, autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, ffmpegExperimental, setFfmpegExperimental, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, AutoExportToggler, renderCaptureFormatButton, onTunerRequested, enableTransferTimestamps, setEnableTransferTimestamps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStoreBuild) loadMifiLink().then(setMifiLink);
|
||||
|
@ -10,6 +10,7 @@ const Settings = memo(({
|
||||
invertTimelineScroll, setInvertTimelineScroll, ffmpegExperimental, setFfmpegExperimental,
|
||||
enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction,
|
||||
hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments,
|
||||
enableTransferTimestamps, setEnableTransferTimestamps,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@ -88,6 +89,17 @@ const Settings = memo(({
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>{t('Set file modification date/time of output files to:')}</KeyCell>
|
||||
<Table.TextCell>
|
||||
<SegmentedControl
|
||||
options={[{ label: t('Source file\'s time'), value: 'true' }, { label: t('Current time'), value: 'false' }]}
|
||||
value={enableTransferTimestamps ? 'true' : 'false'}
|
||||
onChange={value => setEnableTransferTimestamps(value === 'true')}
|
||||
/>
|
||||
</Table.TextCell>
|
||||
</Row>
|
||||
|
||||
<Row>
|
||||
<KeyCell>
|
||||
{t('Keyframe cut mode')}<br />
|
||||
|
@ -19,16 +19,17 @@ function getFrameFromVideo(video, format) {
|
||||
return strongDataUri.decode(dataUri);
|
||||
}
|
||||
|
||||
export async function captureFrameFfmpeg({ customOutDir, filePath, currentTime, captureFormat }) {
|
||||
export async function captureFrameFfmpeg({ customOutDir, filePath, currentTime, captureFormat, enableTransferTimestamps }) {
|
||||
const time = formatDuration({ seconds: currentTime, fileNameFriendly: true });
|
||||
|
||||
const outPath = getOutPath(customOutDir, filePath, `${time}.${captureFormat}`);
|
||||
await ffmpegCaptureFrame({ timestamp: currentTime, videoPath: filePath, outPath });
|
||||
await transferTimestamps(filePath, outPath, currentTime);
|
||||
|
||||
if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime);
|
||||
return outPath;
|
||||
}
|
||||
|
||||
export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video }) {
|
||||
export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }) {
|
||||
const buf = getFrameFromVideo(video, captureFormat);
|
||||
|
||||
const ext = mime.extension(buf.mimetype);
|
||||
@ -37,6 +38,6 @@ export async function captureFrameFromTag({ customOutDir, filePath, currentTime,
|
||||
const outPath = getOutPath(customOutDir, filePath, `${time}.${ext}`);
|
||||
await fs.writeFile(outPath, buf);
|
||||
|
||||
await transferTimestamps(filePath, outPath, currentTime);
|
||||
if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime);
|
||||
return outPath;
|
||||
}
|
||||
|
409
src/ffmpeg.js
409
src/ffmpeg.js
@ -1,26 +1,22 @@
|
||||
import pMap from 'p-map';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import flatMapDeep from 'lodash/flatMapDeep';
|
||||
import sum from 'lodash/sum';
|
||||
import sortBy from 'lodash/sortBy';
|
||||
import moment from 'moment';
|
||||
import i18n from 'i18next';
|
||||
import Timecode from 'smpte-timecode';
|
||||
|
||||
import { getOutPath, getOutDir, transferTimestamps, isDurationValid, getExtensionForFormat, getOutFileExtension } from './util';
|
||||
import { getOutPath, isDurationValid, getExtensionForFormat } from './util';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const { join } = window.require('path');
|
||||
const fileType = window.require('file-type');
|
||||
const readChunk = window.require('read-chunk');
|
||||
const readline = window.require('readline');
|
||||
const stringToStream = window.require('string-to-stream');
|
||||
const isDev = window.require('electron-is-dev');
|
||||
const os = window.require('os');
|
||||
const fs = window.require('fs-extra');
|
||||
|
||||
|
||||
function getFfCommandLine(cmd, args) {
|
||||
export function getFfCommandLine(cmd, args) {
|
||||
const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg);
|
||||
return `${cmd} ${args.map(mapArg).join(' ')}`;
|
||||
}
|
||||
@ -38,23 +34,23 @@ function getFfPath(cmd) {
|
||||
: join(window.process.resourcesPath, `node_modules/ffmpeg-ffprobe-static/${exeName}`);
|
||||
}
|
||||
|
||||
const getFfmpegPath = () => getFfPath('ffmpeg');
|
||||
const getFfprobePath = () => getFfPath('ffprobe');
|
||||
export const getFfmpegPath = () => getFfPath('ffmpeg');
|
||||
export const getFfprobePath = () => getFfPath('ffprobe');
|
||||
|
||||
async function runFfprobe(args) {
|
||||
export async function runFfprobe(args) {
|
||||
const ffprobePath = getFfprobePath();
|
||||
console.log(getFfCommandLine('ffprobe', args));
|
||||
return execa(ffprobePath, args);
|
||||
}
|
||||
|
||||
function runFfmpeg(args) {
|
||||
export function runFfmpeg(args) {
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
console.log(getFfCommandLine('ffmpeg', args));
|
||||
return execa(ffmpegPath, args);
|
||||
}
|
||||
|
||||
|
||||
function handleProgress(process, cutDuration, onProgress) {
|
||||
export function handleProgress(process, cutDuration, onProgress) {
|
||||
onProgress(0);
|
||||
|
||||
const rl = readline.createInterface({ input: process.stderr });
|
||||
@ -167,178 +163,6 @@ export function findNearestKeyFrameTime({ frames, time, direction, fps }) {
|
||||
return nearestFrame.time;
|
||||
}
|
||||
|
||||
function getMovFlags({ preserveMovData, movFastStart }) {
|
||||
const flags = [];
|
||||
|
||||
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
|
||||
// https://video.stackexchange.com/a/26084/29486
|
||||
if (preserveMovData) flags.push('use_metadata_tags');
|
||||
|
||||
// https://github.com/mifi/lossless-cut/issues/347
|
||||
if (movFastStart) flags.push('+faststart');
|
||||
|
||||
if (flags.length === 0) return [];
|
||||
return flatMap(flags, flag => ['-movflags', flag]);
|
||||
}
|
||||
|
||||
async function cut({
|
||||
filePath, outFormat, cutFrom, cutTo, videoDuration, rotation, ffmpegExperimental,
|
||||
onProgress, copyFileStreams, keyframeCut, outPath, appendFfmpegCommandLog, shortestFlag, preserveMovData, movFastStart,
|
||||
avoidNegativeTs, customTagsByFile, customTagsByStreamId,
|
||||
}) {
|
||||
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 inputArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
|
||||
const inputCutArgs = ssBeforeInput ? [
|
||||
...cutFromArgs,
|
||||
...inputArgs,
|
||||
...cutToArgs,
|
||||
...avoidNegativeTsArgs,
|
||||
] : [
|
||||
...inputArgs,
|
||||
...cutFromArgs,
|
||||
...cutToArgs,
|
||||
];
|
||||
|
||||
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
|
||||
|
||||
function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) {
|
||||
let streamCount = 0;
|
||||
const found = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => {
|
||||
if (path2 === inputFilePath) return true;
|
||||
streamCount += streamIds.length;
|
||||
return false;
|
||||
});
|
||||
if (!found) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
|
||||
return streamCount + inputFileStreamIndex;
|
||||
}
|
||||
|
||||
const customTagsArgs = [
|
||||
// We only support editing main file metadata for now
|
||||
...flatMap(Object.entries(customTagsByFile[filePath] || []), ([key, value]) => ['-metadata', `${key}=${value}`]),
|
||||
|
||||
// The structure is deep! Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } }
|
||||
...flatMapDeep(
|
||||
Object.entries(customTagsByStreamId), ([path, streamsMap]) => (
|
||||
Object.entries(streamsMap).map(([streamId, tagsMap]) => (
|
||||
Object.entries(tagsMap).map(([key, value]) => {
|
||||
const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10));
|
||||
if (outputIndex == null) return [];
|
||||
return [`-metadata:s:${outputIndex}`, `${key}=${value}`];
|
||||
})))),
|
||||
),
|
||||
];
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
...inputCutArgs,
|
||||
|
||||
'-c', 'copy',
|
||||
|
||||
...(shortestFlag ? ['-shortest'] : []),
|
||||
|
||||
...flatMapDeep(copyFileStreamsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
|
||||
'-map_metadata', '0',
|
||||
|
||||
...getMovFlags({ preserveMovData, movFastStart }),
|
||||
|
||||
...customTagsArgs,
|
||||
|
||||
// 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'] : []),
|
||||
|
||||
...rotationArgs,
|
||||
|
||||
'-f', outFormat, '-y', outPath,
|
||||
];
|
||||
|
||||
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 transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
export async function cutMultiple({
|
||||
outputDir, filePath, segments, segmentsFileNames, videoDuration, rotation,
|
||||
onProgress, keyframeCut, copyFileStreams, outFormat,
|
||||
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
|
||||
customTagsByFile, customTagsByStreamId,
|
||||
}) {
|
||||
console.log('customTagsByFile', customTagsByFile);
|
||||
console.log('customTagsByStreamId', customTagsByStreamId);
|
||||
|
||||
const singleProgresses = {};
|
||||
function onSingleProgress(id, singleProgress) {
|
||||
singleProgresses[id] = singleProgress;
|
||||
return onProgress((sum(Object.values(singleProgresses)) / segments.length));
|
||||
}
|
||||
|
||||
const outFiles = [];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax,no-unused-vars
|
||||
for (const [i, { start, end }] of segments.entries()) {
|
||||
const fileName = segmentsFileNames[i];
|
||||
|
||||
const outPath = join(outputDir, fileName);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await cut({
|
||||
outPath,
|
||||
filePath,
|
||||
outFormat,
|
||||
videoDuration,
|
||||
rotation,
|
||||
copyFileStreams,
|
||||
keyframeCut,
|
||||
cutFrom: start,
|
||||
cutTo: end,
|
||||
shortestFlag,
|
||||
// eslint-disable-next-line no-loop-func
|
||||
onProgress: progress => onSingleProgress(i, progress),
|
||||
appendFfmpegCommandLog,
|
||||
ffmpegExperimental,
|
||||
preserveMovData,
|
||||
movFastStart,
|
||||
avoidNegativeTs,
|
||||
customTagsByFile,
|
||||
customTagsByStreamId,
|
||||
});
|
||||
|
||||
outFiles.push(outPath);
|
||||
}
|
||||
|
||||
return outFiles;
|
||||
}
|
||||
|
||||
export async function tryReadChaptersToEdl(filePath) {
|
||||
try {
|
||||
const { stdout } = await runFfprobe(['-i', filePath, '-show_chapters', '-print_format', 'json']);
|
||||
@ -375,189 +199,6 @@ export async function getDuration(filePath) {
|
||||
return parseFloat((await getFormatData(filePath)).duration);
|
||||
}
|
||||
|
||||
export async function html5ify({ filePath, outPath, video, audio, onProgress }) {
|
||||
console.log('Making HTML5 friendly version', { filePath, outPath, video, audio });
|
||||
|
||||
let videoArgs;
|
||||
let audioArgs;
|
||||
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
switch (video) {
|
||||
case 'hq': {
|
||||
if (isMac) {
|
||||
videoArgs = ['-vf', 'format=yuv420p', '-allow_sw', '1', '-vcodec', 'h264', '-b:v', '15M'];
|
||||
} else {
|
||||
videoArgs = ['-vf', 'format=yuv420p', '-vcodec', 'libx264', '-profile:v', 'high', '-preset:v', 'slow', '-crf', '17'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lq': {
|
||||
if (isMac) {
|
||||
videoArgs = ['-vf', 'scale=-2:400,format=yuv420p', '-allow_sw', '1', '-sws_flags', 'lanczos', '-vcodec', 'h264', '-b:v', '1500k'];
|
||||
} else {
|
||||
videoArgs = ['-vf', 'scale=-2:400,format=yuv420p', '-sws_flags', 'neighbor', '-vcodec', 'libx264', '-profile:v', 'baseline', '-x264opts', 'level=3.0', '-preset:v', 'ultrafast', '-crf', '28'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
videoArgs = ['-vcodec', 'copy'];
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
videoArgs = ['-vn'];
|
||||
}
|
||||
}
|
||||
|
||||
switch (audio) {
|
||||
case 'hq': {
|
||||
audioArgs = ['-acodec', 'aac', '-b:a', '192k'];
|
||||
break;
|
||||
}
|
||||
case 'lq-flac': {
|
||||
audioArgs = ['-acodec', 'flac', '-ar', '11025', '-ac', '2'];
|
||||
break;
|
||||
}
|
||||
case 'lq-aac': {
|
||||
audioArgs = ['-acodec', 'aac', '-ar', '44100', '-ac', '2', '-b:a', '96k'];
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
audioArgs = ['-acodec', 'copy'];
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
audioArgs = ['-an'];
|
||||
}
|
||||
}
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', filePath,
|
||||
...videoArgs,
|
||||
...audioArgs,
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const duration = await getDuration(filePath);
|
||||
const process = runFfmpeg(ffmpegArgs);
|
||||
if (duration) handleProgress(process, duration, onProgress);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
// This is just used to load something into the player with correct length,
|
||||
// so user can seek and then we render frames using ffmpeg
|
||||
export async function html5ifyDummy(filePath, outPath, onProgress) {
|
||||
console.log('Making HTML5 friendly dummy', { filePath, outPath });
|
||||
|
||||
const duration = await getDuration(filePath);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
// This is just a fast way of generating an empty dummy file
|
||||
'-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
|
||||
'-t', duration,
|
||||
'-acodec', 'flac',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const process = runFfmpeg(ffmpegArgs);
|
||||
handleProgress(process, duration, onProgress);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
if (!chapters) return undefined;
|
||||
|
||||
const path = join(outDir, `ffmetadata-${new Date().getTime()}.txt`);
|
||||
|
||||
const ffmetadata = chapters.map(({ start, end, name }, i) => {
|
||||
const nameOut = name || `Chapter ${i + 1}`;
|
||||
return `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${nameOut}`;
|
||||
}).join('\n\n');
|
||||
// console.log(ffmetadata);
|
||||
await fs.writeFile(path, ffmetadata);
|
||||
return path;
|
||||
}
|
||||
|
||||
export async function mergeFiles({ paths, outDir, outPath, allStreams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }) {
|
||||
console.log('Merging files', { paths }, 'to', outPath);
|
||||
|
||||
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
||||
const totalDuration = sum(durations);
|
||||
|
||||
const ffmetadataPath = await writeChaptersFfmetadata(outDir, chapters);
|
||||
|
||||
try {
|
||||
// Keep this similar to cut()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
|
||||
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
|
||||
|
||||
// Add the first file for using its metadata. Can only do this if allStreams (-map 0) is set, or else ffmpeg might output this input instead of the concat
|
||||
...(preserveMetadataOnMerge && allStreams ? ['-i', paths[0]] : []),
|
||||
|
||||
// Chapters?
|
||||
...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
|
||||
|
||||
'-c', 'copy',
|
||||
|
||||
...(allStreams ? ['-map', '0'] : []),
|
||||
|
||||
// Use the file index 1 for metadata
|
||||
// -map_metadata 0 with concat demuxer doesn't seem to preserve metadata when merging.
|
||||
// Can only do this if allStreams (-map 0) is set
|
||||
...(preserveMetadataOnMerge && allStreams ? ['-map_metadata', '1'] : []),
|
||||
|
||||
...getMovFlags({ preserveMovData, movFastStart }),
|
||||
|
||||
// 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'] : []),
|
||||
|
||||
...(outFormat ? ['-f', outFormat] : []),
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
console.log('ffmpeg', ffmpegArgs.join(' '));
|
||||
|
||||
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
|
||||
const concatTxt = paths.map(file => `file '${join(file).replace(/'/g, "'\\''")}'`).join('\n');
|
||||
|
||||
console.log(concatTxt);
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
const process = execa(ffmpegPath, ffmpegArgs);
|
||||
|
||||
handleProgress(process, totalDuration, onProgress);
|
||||
|
||||
stringToStream(concatTxt).pipe(process.stdin);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
} finally {
|
||||
if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
|
||||
}
|
||||
|
||||
await transferTimestamps(paths[0], outPath);
|
||||
}
|
||||
|
||||
export async function createChaptersFromSegments({ segmentPaths, chapterNames }) {
|
||||
if (chapterNames) {
|
||||
try {
|
||||
@ -575,18 +216,6 @@ export async function createChaptersFromSegments({ segmentPaths, chapterNames })
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export async function autoMergeSegments({ customOutDir, sourceFile, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge }) {
|
||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath: sourceFile });
|
||||
const fileName = `cut-merged-${new Date().getTime()}${ext}`;
|
||||
const outPath = getOutPath(customOutDir, sourceFile, fileName);
|
||||
const outDir = getOutDir(customOutDir, sourceFile);
|
||||
|
||||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||
|
||||
await mergeFiles({ paths: segmentPaths, outDir, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||
}
|
||||
|
||||
/**
|
||||
* ffmpeg only supports encoding certain formats, and some of the detected input
|
||||
* formats are not the same as the names used for encoding.
|
||||
@ -922,30 +551,6 @@ export function encodeLiveRawStream({ path, inWidth, inHeight, seekTo, streamInd
|
||||
};
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/34118013/how-to-determine-webm-duration-using-ffprobe
|
||||
export async function fixInvalidDuration({ filePath, fileFormat, customOutDir }) {
|
||||
const ext = getOutFileExtension({ outFormat: fileFormat, filePath });
|
||||
const fileName = `reformatted${ext}`;
|
||||
const outPath = getOutPath(customOutDir, filePath, fileName);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', filePath,
|
||||
|
||||
'-c', 'copy',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
// TODO progress
|
||||
const { stdout } = await runFfmpeg(ffmpegArgs);
|
||||
console.log(stdout);
|
||||
|
||||
await transferTimestamps(filePath, outPath);
|
||||
|
||||
return outPath;
|
||||
}
|
||||
|
||||
function parseTimecode(str, frameRate) {
|
||||
// console.log(str, frameRate);
|
||||
const t = Timecode(str, frameRate ? parseFloat(frameRate.toFixed(3)) : undefined);
|
||||
|
395
src/hooks/useFfmpegOperations.js
Normal file
395
src/hooks/useFfmpegOperations.js
Normal file
@ -0,0 +1,395 @@
|
||||
import { useCallback } from 'react';
|
||||
import flatMap from 'lodash/flatMap';
|
||||
import flatMapDeep from 'lodash/flatMapDeep';
|
||||
import sum from 'lodash/sum';
|
||||
import pMap from 'p-map';
|
||||
|
||||
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir } from '../util';
|
||||
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
|
||||
|
||||
const execa = window.require('execa');
|
||||
const os = window.require('os');
|
||||
const { join } = window.require('path');
|
||||
const fs = window.require('fs-extra');
|
||||
const stringToStream = window.require('string-to-stream');
|
||||
|
||||
async function writeChaptersFfmetadata(outDir, chapters) {
|
||||
if (!chapters) return undefined;
|
||||
|
||||
const path = join(outDir, `ffmetadata-${new Date().getTime()}.txt`);
|
||||
|
||||
const ffmetadata = chapters.map(({ start, end, name }, i) => {
|
||||
const nameOut = name || `Chapter ${i + 1}`;
|
||||
return `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${nameOut}`;
|
||||
}).join('\n\n');
|
||||
// console.log(ffmetadata);
|
||||
await fs.writeFile(path, ffmetadata);
|
||||
return path;
|
||||
}
|
||||
|
||||
function getMovFlags({ preserveMovData, movFastStart }) {
|
||||
const flags = [];
|
||||
|
||||
// https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
|
||||
// https://video.stackexchange.com/a/26084/29486
|
||||
if (preserveMovData) flags.push('use_metadata_tags');
|
||||
|
||||
// https://github.com/mifi/lossless-cut/issues/347
|
||||
if (movFastStart) flags.push('+faststart');
|
||||
|
||||
if (flags.length === 0) return [];
|
||||
return flatMap(flags, flag => ['-movflags', flag]);
|
||||
}
|
||||
|
||||
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
|
||||
const optionalTransferTimestamps = useCallback(async (...args) => {
|
||||
if (enableTransferTimestamps) await transferTimestamps(...args);
|
||||
}, [enableTransferTimestamps]);
|
||||
|
||||
// const cut = useCallback(, [filePath, optionalTransferTimestamps]);
|
||||
|
||||
const cutMultiple = useCallback(async ({
|
||||
outputDir, segments, segmentsFileNames, videoDuration, rotation,
|
||||
onProgress: onTotalProgress, keyframeCut, copyFileStreams, outFormat,
|
||||
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
|
||||
customTagsByFile, customTagsByStreamId,
|
||||
}) => {
|
||||
async function cutSingle({ cutFrom, cutTo, 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 inputArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
|
||||
const inputCutArgs = ssBeforeInput ? [
|
||||
...cutFromArgs,
|
||||
...inputArgs,
|
||||
...cutToArgs,
|
||||
...avoidNegativeTsArgs,
|
||||
] : [
|
||||
...inputArgs,
|
||||
...cutFromArgs,
|
||||
...cutToArgs,
|
||||
];
|
||||
|
||||
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
|
||||
|
||||
function mapInputStreamIndexToOutputIndex(inputFilePath, inputFileStreamIndex) {
|
||||
let streamCount = 0;
|
||||
const found = copyFileStreamsFiltered.find(({ path: path2, streamIds }) => {
|
||||
if (path2 === inputFilePath) return true;
|
||||
streamCount += streamIds.length;
|
||||
return false;
|
||||
});
|
||||
if (!found) return undefined; // Could happen if a tag has been edited on an external file, then the file was removed
|
||||
return streamCount + inputFileStreamIndex;
|
||||
}
|
||||
|
||||
const customTagsArgs = [
|
||||
// We only support editing main file metadata for now
|
||||
...flatMap(Object.entries(customTagsByFile[filePath] || []), ([key, value]) => ['-metadata', `${key}=${value}`]),
|
||||
|
||||
// The structure is deep! Example: { 'file.mp4': { 0: { tag_name: 'Tag Value' } } }
|
||||
...flatMapDeep(
|
||||
Object.entries(customTagsByStreamId), ([path, streamsMap]) => (
|
||||
Object.entries(streamsMap).map(([streamId, tagsMap]) => (
|
||||
Object.entries(tagsMap).map(([key, value]) => {
|
||||
const outputIndex = mapInputStreamIndexToOutputIndex(path, parseInt(streamId, 10));
|
||||
if (outputIndex == null) return [];
|
||||
return [`-metadata:s:${outputIndex}`, `${key}=${value}`];
|
||||
})))),
|
||||
),
|
||||
];
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
...inputCutArgs,
|
||||
|
||||
'-c', 'copy',
|
||||
|
||||
...(shortestFlag ? ['-shortest'] : []),
|
||||
|
||||
...flatMapDeep(copyFileStreamsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
|
||||
'-map_metadata', '0',
|
||||
|
||||
...getMovFlags({ preserveMovData, movFastStart }),
|
||||
|
||||
...customTagsArgs,
|
||||
|
||||
// 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'] : []),
|
||||
|
||||
...rotationArgs,
|
||||
|
||||
'-f', outFormat, '-y', outPath,
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
console.log('customTagsByFile', customTagsByFile);
|
||||
console.log('customTagsByStreamId', customTagsByStreamId);
|
||||
|
||||
const singleProgresses = {};
|
||||
function onSingleProgress(id, singleProgress) {
|
||||
singleProgresses[id] = singleProgress;
|
||||
return onTotalProgress((sum(Object.values(singleProgresses)) / segments.length));
|
||||
}
|
||||
|
||||
const outFiles = [];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax,no-unused-vars
|
||||
for (const [i, { start: cutFrom, end: cutTo }] of segments.entries()) {
|
||||
const fileName = segmentsFileNames[i];
|
||||
|
||||
const outPath = join(outputDir, fileName);
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await cutSingle({ cutFrom, cutTo, outPath, onProgress: progress => onSingleProgress(i, progress) });
|
||||
|
||||
outFiles.push(outPath);
|
||||
}
|
||||
|
||||
return outFiles;
|
||||
}, [filePath, optionalTransferTimestamps]);
|
||||
|
||||
const mergeFiles = useCallback(async ({ paths, outDir, outPath, allStreams, outFormat, ffmpegExperimental, onProgress = () => {}, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }) => {
|
||||
console.log('Merging files', { paths }, 'to', outPath);
|
||||
|
||||
const durations = await pMap(paths, getDuration, { concurrency: 1 });
|
||||
const totalDuration = sum(durations);
|
||||
|
||||
const ffmetadataPath = await writeChaptersFfmetadata(outDir, chapters);
|
||||
|
||||
try {
|
||||
// Keep this similar to cutSingle()
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
// No progress if we set loglevel warning :(
|
||||
// '-loglevel', 'warning',
|
||||
|
||||
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
|
||||
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
|
||||
|
||||
// Add the first file for using its metadata. Can only do this if allStreams (-map 0) is set, or else ffmpeg might output this input instead of the concat
|
||||
...(preserveMetadataOnMerge && allStreams ? ['-i', paths[0]] : []),
|
||||
|
||||
// Chapters?
|
||||
...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
|
||||
|
||||
'-c', 'copy',
|
||||
|
||||
...(allStreams ? ['-map', '0'] : []),
|
||||
|
||||
// Use the file index 1 for metadata
|
||||
// -map_metadata 0 with concat demuxer doesn't seem to preserve metadata when merging.
|
||||
// Can only do this if allStreams (-map 0) is set
|
||||
...(preserveMetadataOnMerge && allStreams ? ['-map_metadata', '1'] : []),
|
||||
|
||||
...getMovFlags({ preserveMovData, movFastStart }),
|
||||
|
||||
// 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'] : []),
|
||||
|
||||
...(outFormat ? ['-f', outFormat] : []),
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
console.log('ffmpeg', ffmpegArgs.join(' '));
|
||||
|
||||
// https://superuser.com/questions/787064/filename-quoting-in-ffmpeg-concat
|
||||
const concatTxt = paths.map(file => `file '${join(file).replace(/'/g, "'\\''")}'`).join('\n');
|
||||
|
||||
console.log(concatTxt);
|
||||
|
||||
const ffmpegPath = getFfmpegPath();
|
||||
const process = execa(ffmpegPath, ffmpegArgs);
|
||||
|
||||
handleProgress(process, totalDuration, onProgress);
|
||||
|
||||
stringToStream(concatTxt).pipe(process.stdin);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
} finally {
|
||||
if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
|
||||
}
|
||||
|
||||
await optionalTransferTimestamps(paths[0], outPath);
|
||||
}, [optionalTransferTimestamps]);
|
||||
|
||||
const autoMergeSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge }) => {
|
||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath });
|
||||
const fileName = `cut-merged-${new Date().getTime()}${ext}`;
|
||||
const outPath = getOutPath(customOutDir, filePath, fileName);
|
||||
const outDir = getOutDir(customOutDir, filePath);
|
||||
|
||||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||
|
||||
await mergeFiles({ paths: segmentPaths, outDir, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
|
||||
}, [filePath, mergeFiles]);
|
||||
|
||||
const html5ify = useCallback(async ({ filePath: specificFilePath, outPath, video, audio, onProgress }) => {
|
||||
console.log('Making HTML5 friendly version', { specificFilePath, outPath, video, audio });
|
||||
|
||||
let videoArgs;
|
||||
let audioArgs;
|
||||
|
||||
const isMac = os.platform() === 'darwin';
|
||||
|
||||
switch (video) {
|
||||
case 'hq': {
|
||||
if (isMac) {
|
||||
videoArgs = ['-vf', 'format=yuv420p', '-allow_sw', '1', '-vcodec', 'h264', '-b:v', '15M'];
|
||||
} else {
|
||||
videoArgs = ['-vf', 'format=yuv420p', '-vcodec', 'libx264', '-profile:v', 'high', '-preset:v', 'slow', '-crf', '17'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'lq': {
|
||||
if (isMac) {
|
||||
videoArgs = ['-vf', 'scale=-2:400,format=yuv420p', '-allow_sw', '1', '-sws_flags', 'lanczos', '-vcodec', 'h264', '-b:v', '1500k'];
|
||||
} else {
|
||||
videoArgs = ['-vf', 'scale=-2:400,format=yuv420p', '-sws_flags', 'neighbor', '-vcodec', 'libx264', '-profile:v', 'baseline', '-x264opts', 'level=3.0', '-preset:v', 'ultrafast', '-crf', '28'];
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
videoArgs = ['-vcodec', 'copy'];
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
videoArgs = ['-vn'];
|
||||
}
|
||||
}
|
||||
|
||||
switch (audio) {
|
||||
case 'hq': {
|
||||
audioArgs = ['-acodec', 'aac', '-b:a', '192k'];
|
||||
break;
|
||||
}
|
||||
case 'lq-flac': {
|
||||
audioArgs = ['-acodec', 'flac', '-ar', '11025', '-ac', '2'];
|
||||
break;
|
||||
}
|
||||
case 'lq-aac': {
|
||||
audioArgs = ['-acodec', 'aac', '-ar', '44100', '-ac', '2', '-b:a', '96k'];
|
||||
break;
|
||||
}
|
||||
case 'copy': {
|
||||
audioArgs = ['-acodec', 'copy'];
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
audioArgs = ['-an'];
|
||||
}
|
||||
}
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', specificFilePath,
|
||||
...videoArgs,
|
||||
...audioArgs,
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const duration = await getDuration(specificFilePath);
|
||||
const process = runFfmpeg(ffmpegArgs);
|
||||
if (duration) handleProgress(process, duration, onProgress);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
|
||||
await optionalTransferTimestamps(specificFilePath, outPath);
|
||||
}, [optionalTransferTimestamps]);
|
||||
|
||||
// This is just used to load something into the player with correct length,
|
||||
// so user can seek and then we render frames using ffmpeg
|
||||
const html5ifyDummy = useCallback(async ({ filePath: specificFilePath, outPath, onProgress }) => {
|
||||
console.log('Making HTML5 friendly dummy', { specificFilePath, outPath });
|
||||
|
||||
const duration = await getDuration(specificFilePath);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
// This is just a fast way of generating an empty dummy file
|
||||
'-f', 'lavfi', '-i', 'anullsrc=channel_layout=stereo:sample_rate=44100',
|
||||
'-t', duration,
|
||||
'-acodec', 'flac',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
const process = runFfmpeg(ffmpegArgs);
|
||||
handleProgress(process, duration, onProgress);
|
||||
|
||||
const { stdout } = await process;
|
||||
console.log(stdout);
|
||||
|
||||
await optionalTransferTimestamps(specificFilePath, outPath);
|
||||
}, [optionalTransferTimestamps]);
|
||||
|
||||
// https://stackoverflow.com/questions/34118013/how-to-determine-webm-duration-using-ffprobe
|
||||
const fixInvalidDuration = useCallback(async ({ fileFormat, customOutDir }) => {
|
||||
const ext = getOutFileExtension({ outFormat: fileFormat, filePath });
|
||||
const fileName = `reformatted${ext}`;
|
||||
const outPath = getOutPath(customOutDir, filePath, fileName);
|
||||
|
||||
const ffmpegArgs = [
|
||||
'-hide_banner',
|
||||
|
||||
'-i', filePath,
|
||||
|
||||
'-c', 'copy',
|
||||
'-y', outPath,
|
||||
];
|
||||
|
||||
// TODO progress
|
||||
const { stdout } = await runFfmpeg(ffmpegArgs);
|
||||
console.log(stdout);
|
||||
|
||||
await optionalTransferTimestamps(filePath, outPath);
|
||||
|
||||
return outPath;
|
||||
}, [filePath, optionalTransferTimestamps]);
|
||||
|
||||
return {
|
||||
cutMultiple, mergeFiles, html5ify, html5ifyDummy, fixInvalidDuration, autoMergeSegments,
|
||||
};
|
||||
}
|
||||
|
||||
export default useFfmpegOperations;
|
@ -82,6 +82,8 @@ export default () => {
|
||||
useEffect(() => safeSetConfig('keyboardSeekAccFactor', keyboardSeekAccFactor), [keyboardSeekAccFactor]);
|
||||
const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(configStore.get('keyboardNormalSeekSpeed'));
|
||||
useEffect(() => safeSetConfig('keyboardNormalSeekSpeed', keyboardNormalSeekSpeed), [keyboardNormalSeekSpeed]);
|
||||
const [enableTransferTimestamps, setEnableTransferTimestamps] = useState(configStore.get('enableTransferTimestamps'));
|
||||
useEffect(() => safeSetConfig('enableTransferTimestamps', enableTransferTimestamps), [enableTransferTimestamps]);
|
||||
|
||||
|
||||
// NOTE! This useEffect must be placed after all usages of firstUpdateRef.current (safeSetConfig)
|
||||
@ -148,5 +150,7 @@ export default () => {
|
||||
setKeyboardSeekAccFactor,
|
||||
keyboardNormalSeekSpeed,
|
||||
setKeyboardNormalSeekSpeed,
|
||||
enableTransferTimestamps,
|
||||
setEnableTransferTimestamps,
|
||||
};
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user