1
0
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:
Mikael Finstad 2021-02-18 18:57:53 +01:00
parent 5704713a06
commit e72d0a6953
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
7 changed files with 447 additions and 424 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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