From eb8f7af5e039c6f1f1dee2da0063b1da963391e0 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sat, 23 Jan 2021 19:02:33 +0100 Subject: [PATCH] Implement custom file name templates #96 --- public/configStore.js | 1 + src/App.jsx | 56 ++++++++++++--- src/ExportConfirm.jsx | 23 +++++-- src/components/HighlightedText.jsx | 6 ++ src/components/OutSegTemplateEditor.jsx | 90 +++++++++++++++++++++++++ src/ffmpeg.js | 43 +++--------- src/util.js | 24 +++++++ 7 files changed, 194 insertions(+), 49 deletions(-) create mode 100644 src/components/HighlightedText.jsx create mode 100644 src/components/OutSegTemplateEditor.jsx diff --git a/public/configStore.js b/public/configStore.js index 055497d0..3ca2a488 100644 --- a/public/configStore.js +++ b/public/configStore.js @@ -27,6 +27,7 @@ const defaults = { segmentsToChapters: false, preserveMetadataOnMerge: false, simpleMode: true, + outSegTemplate: undefined, }, }; diff --git a/src/App.jsx b/src/App.jsx index e5ab1bdb..840594b9 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -49,7 +49,8 @@ import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, saveCsvHuman } import { getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur, checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile, - isDurationValid, isWindows, + isDurationValid, isWindows, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, + hasDuplicates, } from './util'; import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull } from './dialogs'; import { openSendReportDialog } from './reporting'; @@ -64,7 +65,7 @@ const isDev = window.require('electron-is-dev'); const electron = window.require('electron'); // eslint-disable-line const trash = window.require('trash'); const { unlink, exists } = window.require('fs-extra'); -const { extname, parse: parsePath } = window.require('path'); +const { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize } = window.require('path'); const { dialog, app } = electron.remote; @@ -211,6 +212,10 @@ const App = memo(() => { useEffect(() => safeSetConfig('preserveMetadataOnMerge', preserveMetadataOnMerge), [preserveMetadataOnMerge]); const [simpleMode, setSimpleMode] = useState(configStore.get('simpleMode')); useEffect(() => safeSetConfig('simpleMode', simpleMode), [simpleMode]); + const [outSegTemplate, setOutSegTemplate] = useState(configStore.get('outSegTemplate')); + useEffect(() => safeSetConfig('outSegTemplate', outSegTemplate), [outSegTemplate]); + + const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate; useEffect(() => { i18n.changeLanguage(language || fallbackLng).catch(console.error); @@ -982,6 +987,33 @@ const App = memo(() => { const outSegments = useMemo(() => (invertCutSegments ? inverseCutSegments : apparentCutSegments), [invertCutSegments, inverseCutSegments, apparentCutSegments]); + const generateOutSegFileNames = useCallback(({ segments = outSegments, template }) => ( + segments.map(({ start, end, name = '' }, i) => { + const cutFromStr = formatDuration({ seconds: start, fileNameFriendly: true }); + const cutToStr = formatDuration({ seconds: end, fileNameFriendly: true }); + const segNum = i + 1; + + // https://github.com/mifi/lossless-cut/issues/583 + let segSuffix = ''; + if (name) segSuffix = `-${filenamify(name)}`; + else if (segments.length > 1) segSuffix = `-seg${segNum}`; + + const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }); + + const { name: fileNameWithoutExt } = parsePath(filePath); + + const generated = generateSegFileName({ template, segSuffix, inputFileNameWithoutExt: fileNameWithoutExt, ext, segNum, segLabel: filenamify(name), cutFrom: cutFromStr, cutTo: cutToStr }); + return generated.substr(0, 200); // Just to be sure + }) + ), [fileFormat, filePath, isCustomFormatSelected, outSegments]); + + // TODO improve user feedback + const isOutSegFileNamesValid = useCallback((fileNames) => fileNames.every((fileName) => { + if (!filePath) return false; + const sameAsInputPath = pathNormalize(pathJoin(outputDir, fileName)) === pathNormalize(filePath); + return fileName.length > 0 && !fileName.includes(pathSep) && !sameAsInputPath; + }), [outputDir, filePath]); + const openSendReportDialogWithState = useCallback(async (err) => { const state = { filePath, @@ -1035,24 +1067,30 @@ const App = memo(() => { setStreamsSelectorShown(false); setExportConfirmVisible(false); - const outSegmentsWithOrder = outSegments.map((s, order) => ({ ...s, order })); - const filteredOutSegments = exportSingle ? [outSegmentsWithOrder[currentSegIndexSafe]] : outSegmentsWithOrder; + const filteredOutSegments = exportSingle ? [outSegments[currentSegIndexSafe]] : outSegments; try { setWorking(i18n.t('Exporting')); + console.log('outSegTemplateOrDefault', outSegTemplateOrDefault); + + let outSegFileNames = generateOutSegFileNames({ segments: filteredOutSegments, template: outSegTemplateOrDefault }); + if (!isOutSegFileNamesValid(outSegFileNames) || hasDuplicates(outSegFileNames)) { + console.error('Output segments file name invalid, using default instead', outSegFileNames); + outSegFileNames = generateOutSegFileNames({ segments: filteredOutSegments, template: defaultOutSegTemplate }); + } + // throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })(); const outFiles = await cutMultiple({ - customOutDir, + outputDir, filePath, outFormat: fileFormat, - isCustomFormatSelected, videoDuration: duration, rotation: isRotationSet ? effectiveRotation : undefined, copyFileStreams, keyframeCut, - invertCutSegments, segments: filteredOutSegments, + segmentsFileNames: outSegFileNames, onProgress: setCutProgress, appendFfmpegCommandLog, shortestFlag, @@ -1118,7 +1156,7 @@ const App = memo(() => { setWorking(); setCutProgress(); } - }, [autoMerge, copyFileStreams, customOutDir, duration, effectiveRotation, exportExtraStreams, ffmpegExperimental, fileFormat, fileFormatData, filePath, handleCutFailed, isCustomFormatSelected, isRotationSet, keyframeCut, mainStreams, nonCopiedExtraStreams, outSegments, outputDir, shortestFlag, working, preserveMovData, movFastStart, avoidNegativeTs, numStreamsToCopy, hideAllNotifications, currentSegIndexSafe, invertCutSegments, autoDeleteMergedSegments, segmentsToChapters, customTagsByFile, customTagsByStreamId, preserveMetadataOnMerge]); + }, [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]); const onExportPress = useCallback(async () => { if (working || !filePath) return; @@ -2252,7 +2290,7 @@ const App = memo(() => { - + {children}; - -const HelpIcon = ({ onClick }) => ; +const HelpIcon = ({ onClick }) => ; const ExportConfirm = memo(({ autoMerge, areWeCutting, outSegments, visible, onClosePress, onExportConfirm, keyframeCut, toggleKeyframeCut, toggleAutoMerge, renderOutFmt, preserveMovData, togglePreserveMovData, movFastStart, toggleMovFastStart, avoidNegativeTs, setAvoidNegativeTs, changeOutDir, outputDir, numStreamsTotal, numStreamsToCopy, setStreamsSelectorShown, currentSegIndex, invertCutSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, toggleSegmentsToChapters, outFormat, - preserveMetadataOnMerge, togglePreserveMetadataOnMerge, + preserveMetadataOnMerge, togglePreserveMetadataOnMerge, outSegTemplate, setOutSegTemplate, generateOutSegFileNames, + filePath, currentSegIndexSafe, isOutSegFileNamesValid, }) => { const { t } = useTranslation(); @@ -80,6 +80,10 @@ const ExportConfirm = memo(({ toast.fire({ icon: 'info', timer: 10000, text: i18n.t('When merging, do you want to preserve metadata from your original file? NOTE: This may dramatically increase processing time') }); } + function onOutSegTemplateHelpPress() { + toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the output segment(s) using special variables.') }); + } + function onAvoidNegativeTsHelpPress() { // https://ffmpeg.org/ffmpeg-all.html#Format-Options const texts = { @@ -96,6 +100,8 @@ const ExportConfirm = memo(({ return segBgColor; } + const outSegTemplateHelpIcon = ; + // https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container return ( @@ -118,12 +124,17 @@ const ExportConfirm = memo(({
  • - Input has {{ numStreamsTotal }} tracks - setStreamsSelectorShown(true)}>Keeping {{ numStreamsToCopy }} tracks + Input has {{ numStreamsTotal }} tracks - setStreamsSelectorShown(true)}>Keeping {{ numStreamsToCopy }} tracks
  • {t('Save output to path:')} {outputDir}
  • + {(outSegments.length === 1 || !autoMerge) && ( +
  • + +
  • + )}

    {t('Advanced options')}

    diff --git a/src/components/HighlightedText.jsx b/src/components/HighlightedText.jsx new file mode 100644 index 00000000..7018a8e2 --- /dev/null +++ b/src/components/HighlightedText.jsx @@ -0,0 +1,6 @@ +import React, { memo } from 'react'; + +// eslint-disable-next-line react/jsx-props-no-spreading +const HighlightedText = memo(({ children, style, ...props }) => {children}); + +export default HighlightedText; diff --git a/src/components/OutSegTemplateEditor.jsx b/src/components/OutSegTemplateEditor.jsx new file mode 100644 index 00000000..ffdae3e7 --- /dev/null +++ b/src/components/OutSegTemplateEditor.jsx @@ -0,0 +1,90 @@ +import React, { memo, useState, useEffect } from 'react'; +import { useDebounce } from 'use-debounce'; +import { useTranslation } from 'react-i18next'; +import { Button, Alert } from 'evergreen-ui'; +import Swal from 'sweetalert2'; +import withReactContent from 'sweetalert2-react-content'; + +import HighlightedText from './HighlightedText'; +import { defaultOutSegTemplate } from '../util'; + +const ReactSwal = withReactContent(Swal); + + +const OutSegTemplateEditor = memo(({ helpIcon, outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe, isOutSegFileNamesValid }) => { + const [text, setText] = useState(outSegTemplate); + const [debouncedText] = useDebounce(text, 500); + const [validText, setValidText] = useState(); + const [error, setError] = useState(); + const [outSegFileNames, setOutSegFileNames] = useState(); + const [shown, setShown] = useState(); + + const { t } = useTranslation(); + + useEffect(() => { + if (debouncedText == null) return; + + try { + const generatedOutSegFileNames = generateOutSegFileNames({ template: debouncedText }); + setOutSegFileNames(generatedOutSegFileNames); + const isOutSegTemplateValid = isOutSegFileNamesValid(generatedOutSegFileNames); + if (!isOutSegTemplateValid) { + setError(t('This template will result in invalid file names')); + setValidText(); + return; + } + + setValidText(debouncedText); + setError(); + } catch (err) { + console.error(err); + setValidText(); + setError(err.message); + } + }, [debouncedText, generateOutSegFileNames, isOutSegFileNamesValid, t]); + + const onAllSegmentsPreviewPress = () => ReactSwal.fire({ title: t('Resulting segment file names'), html:
    {outSegFileNames.map((f) =>
    {f}
    )}
    }); + + useEffect(() => { + if (validText != null) setOutSegTemplate(validText); + }, [validText, setOutSegTemplate]); + + function reset() { + setOutSegTemplate(defaultOutSegTemplate); + setText(defaultOutSegTemplate); + } + + function onToggleClick() { + if (!shown) setShown(true); + else if (error == null) setShown(false); + } + + return ( + <> +
    + + {t('Output name(s):')} {outSegFileNames != null && {outSegFileNames[currentSegIndexSafe]}} + + {helpIcon} +
    + + {shown && ( + <> +
    + setText(e.target.value)} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" /> + {outSegFileNames && } + + +
    +
    + {error != null && {`There is an error in the file name template: ${error}`}} + {/* eslint-disable-next-line no-template-curly-in-string */} +
    {'Variables: ${FILENAME} ${CUT_FROM} ${CUT_TO} ${SEG_NUM} ${SEG_LABEL} ${SEG_SUFFIX} ${EXT}'}
    +
    + + )} + + ); +}); + +export default OutSegTemplateEditor; diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 9bb64710..160d55dc 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -7,10 +7,10 @@ import moment from 'moment'; import i18n from 'i18next'; import Timecode from 'smpte-timecode'; -import { formatDuration, getOutPath, getOutDir, transferTimestamps, filenamify, isDurationValid } from './util'; +import { getOutPath, getOutDir, transferTimestamps, isDurationValid, getExtensionForFormat, getOutFileExtension } from './util'; const execa = window.require('execa'); -const { join, extname } = window.require('path'); +const { join } = window.require('path'); const fileType = window.require('file-type'); const readChunk = window.require('read-chunk'); const readline = window.require('readline'); @@ -88,15 +88,6 @@ export function isCuttingEnd(cutTo, duration) { return cutTo < duration; } -function getExtensionForFormat(format) { - const ext = { - matroska: 'mkv', - ipod: 'm4a', - }[format]; - - return ext || format; -} - function getIntervalAroundTime(time, window) { return { from: Math.max(time - window / 2, 0), @@ -296,15 +287,11 @@ async function cut({ await transferTimestamps(filePath, outPath); } -function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) { - return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : extname(filePath); -} - export async function cutMultiple({ - customOutDir, filePath, segments, videoDuration, rotation, - onProgress, keyframeCut, copyFileStreams, outFormat, isCustomFormatSelected, + outputDir, filePath, segments, segmentsFileNames, videoDuration, rotation, + onProgress, keyframeCut, copyFileStreams, outFormat, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, - customTagsByFile, customTagsByStreamId, invertCutSegments, + customTagsByFile, customTagsByStreamId, }) { console.log('customTagsByFile', customTagsByFile); console.log('customTagsByStreamId', customTagsByStreamId); @@ -317,21 +304,11 @@ export async function cutMultiple({ const outFiles = []; - let i = 0; // eslint-disable-next-line no-restricted-syntax,no-unused-vars - for (const { start, end, name, order } of segments) { - const cutFromStr = formatDuration({ seconds: start, fileNameFriendly: true }); - const cutToStr = formatDuration({ seconds: end, fileNameFriendly: true }); - let segNamePart = ''; - if (!invertCutSegments) { - if (name) segNamePart = `-${filenamify(name)}`; - // https://github.com/mifi/lossless-cut/issues/583 - else if (segments.length > 1) segNamePart = `-seg${order + 1}`; - } - const cutSpecification = `${cutFromStr}-${cutToStr}${segNamePart}`.substr(0, 200); - const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }); - const fileName = `${cutSpecification}${ext}`; - const outPath = getOutPath(customOutDir, filePath, fileName); + 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({ @@ -357,8 +334,6 @@ export async function cutMultiple({ }); outFiles.push(outPath); - - i += 1; } return outFiles; diff --git a/src/util.js b/src/util.js index ecc4e934..008b2cce 100644 --- a/src/util.js +++ b/src/util.js @@ -1,6 +1,7 @@ import padStart from 'lodash/padStart'; import Swal from 'sweetalert2'; import i18n from 'i18next'; +import lodashTemplate from 'lodash/template'; import randomColor from './random-color'; @@ -166,3 +167,26 @@ export const isDurationValid = (duration) => Number.isFinite(duration) && durati const platform = os.platform(); export const isWindows = platform === 'win32'; + +export function getExtensionForFormat(format) { + const ext = { + matroska: 'mkv', + ipod: 'm4a', + }[format]; + + return ext || format; +} + +export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) { + return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath); +} + +// eslint-disable-next-line no-template-curly-in-string +export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}'; + +export function generateSegFileName({ template, inputFileNameWithoutExt, segSuffix, ext, segNum, segLabel, cutFrom, cutTo }) { + const compiled = lodashTemplate(template); + return compiled({ FILENAME: inputFileNameWithoutExt, SEG_SUFFIX: segSuffix, EXT: ext, SEG_NUM: segNum, SEG_LABEL: segLabel, CUT_FROM: cutFrom, CUT_TO: cutTo }); +} + +export const hasDuplicates = (arr) => new Set(arr).size !== arr.length;