From ceda857d6dd6cd0c8200eb102250461511b17d53 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 11 Sep 2022 22:53:00 +0200 Subject: [PATCH] concat improvements - when merging files, the output file name will always contain a unique suffix to prevent overwriting any existing files - the UI will allow the user to enter a custom output file name - rename getOutPath --- src/App.jsx | 20 +++++++------- src/capture-frame.js | 6 ++--- src/components/ConcatDialog.jsx | 46 +++++++++++++++++++++++--------- src/ffmpeg.js | 6 ++--- src/hooks/useFfmpegOperations.js | 10 +++---- src/util.js | 11 +++++--- 6 files changed, 64 insertions(+), 35 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index 07571054..cde4fd63 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -62,7 +62,7 @@ import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, defaul import { exportEdlFile, readEdlFile, saveLlcProject, loadLlcProject, askForEdlImport } from './edlStore'; import { formatYouTube, getFrameCountRaw } from './edlFormats'; import { - getOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, getFileDir, + getOutPath, getSuffixedOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, getFileDir, checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, isDurationValid, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, @@ -583,9 +583,9 @@ const App = memo(() => { const projectSuffix = 'proj.llc'; const oldProjectSuffix = 'llc-edl.csv'; // New LLC format can be stored along with input file or in working dir (customOutDir) - const getEdlFilePath = useCallback((fp, storeProjectInWorkingDir2 = false) => getOutPath({ customOutDir: storeProjectInWorkingDir2 ? customOutDir : undefined, filePath: fp, nameSuffix: projectSuffix }), [customOutDir]); + const getEdlFilePath = useCallback((fp, storeProjectInWorkingDir2 = false) => getSuffixedOutPath({ customOutDir: storeProjectInWorkingDir2 ? customOutDir : undefined, filePath: fp, nameSuffix: projectSuffix }), [customOutDir]); // Old versions of LosslessCut used CSV files and stored them in customOutDir: - const getEdlFilePathOld = useCallback((fp) => getOutPath({ customOutDir, filePath: fp, nameSuffix: oldProjectSuffix }), [customOutDir]); + const getEdlFilePathOld = useCallback((fp) => getSuffixedOutPath({ customOutDir, filePath: fp, nameSuffix: oldProjectSuffix }), [customOutDir]); const projectFileSavePath = useMemo(() => getEdlFilePath(filePath, storeProjectInWorkingDir), [getEdlFilePath, filePath, storeProjectInWorkingDir]); const currentSaveOperation = useMemo(() => { @@ -960,7 +960,7 @@ const App = memo(() => { async function doHtml5ify() { if (speed == null) return undefined; if (speed === 'fastest') { - const path = getOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); + const path = getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${html5dummySuffix}.mkv` }); try { setCutProgress(0); await html5ifyDummy({ filePath: fp, outPath: path, onProgress: setCutProgress }); @@ -1094,19 +1094,21 @@ const App = memo(() => { }); }, []); - const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, isCustomFormatSelected: isCustomFormatSelected2, clearBatchFilesAfterConcat }) => { + const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, fileName: outFileName, clearBatchFilesAfterConcat }) => { if (workingRef.current) return; try { setConcatDialogVisible(false); setWorking(i18n.t('Merging')); const firstPath = paths[0]; + if (!firstPath) return; + const { newCustomOutDir, cancel } = await ensureAccessibleDirectories({ inputPath: firstPath }); if (cancel) return; - const ext = getOutFileExtension({ isCustomFormatSelected: isCustomFormatSelected2, outFormat, filePath: firstPath }); - const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, nameSuffix: `merged${ext}` }); - const outDir = getOutDir(customOutDir, firstPath); + const outDir = getOutDir(newCustomOutDir, firstPath); + + const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, fileName: outFileName }); let chaptersFromSegments; if (segmentsToChapters) { @@ -1130,7 +1132,7 @@ const App = memo(() => { setWorking(); setCutProgress(); } - }, [setWorking, ensureAccessibleDirectories, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch]); + }, [setWorking, ensureAccessibleDirectories, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch]); const cleanupFilesDialog = useCallback(async () => { if (!isFileOpened) return; diff --git a/src/capture-frame.js b/src/capture-frame.js index 11784948..e8f44084 100644 --- a/src/capture-frame.js +++ b/src/capture-frame.js @@ -1,6 +1,6 @@ import strongDataUri from 'strong-data-uri'; -import { getOutPath, transferTimestamps } from './util'; +import { getSuffixedOutPath, transferTimestamps } from './util'; import { formatDuration } from './util/duration'; import { captureFrame as ffmpegCaptureFrame } from './ffmpeg'; @@ -29,7 +29,7 @@ export async function captureFramesFfmpeg({ customOutDir, filePath, fromTime, ca } else { nameSuffix = `${time}.${captureFormat}`; } - const outPath = getOutPath({ customOutDir, filePath, nameSuffix }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix }); await ffmpegCaptureFrame({ timestamp: fromTime, videoPath: filePath, outPath, numFrames }); if (enableTransferTimestamps && numFrames === 1) await transferTimestamps(filePath, outPath, fromTime); @@ -42,7 +42,7 @@ export async function captureFrameFromTag({ customOutDir, filePath, currentTime, const ext = mime.extension(buf.mimetype); const time = formatDuration({ seconds: currentTime, fileNameFriendly: true }); - const outPath = getOutPath({ customOutDir, filePath, nameSuffix: `${time}.${ext}` }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `${time}.${ext}` }); await fs.writeFile(outPath, buf); if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime); diff --git a/src/components/ConcatDialog.jsx b/src/components/ConcatDialog.jsx index 70fb3e76..ceac939a 100644 --- a/src/components/ConcatDialog.jsx +++ b/src/components/ConcatDialog.jsx @@ -1,6 +1,6 @@ import React, { memo, useState, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { IconButton, Alert, Checkbox, Dialog, Button, Paragraph } from 'evergreen-ui'; +import { TextInput, IconButton, Alert, Checkbox, Dialog, Button, Paragraph } from 'evergreen-ui'; import { AiOutlineMergeCells } from 'react-icons/ai'; import { FaQuestionCircle, FaCheckCircle, FaExclamationTriangle } from 'react-icons/fa'; import i18n from 'i18next'; @@ -12,6 +12,7 @@ import useFileFormatState from '../hooks/useFileFormatState'; import OutputFormatSelect from './OutputFormatSelect'; import useUserSettings from '../hooks/useUserSettings'; import { isMov } from '../util/streams'; +import { getOutFileExtension, getFileBaseName } from '../util'; const { basename } = window.require('path'); @@ -36,6 +37,8 @@ const ConcatDialog = memo(({ const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [enableReadFileMeta, setEnableReadFileMeta] = useState(false); + const [outFileName, setOutFileName] = useState(); + const [uniqueSuffix, setUniqueSuffix] = useState(); const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); @@ -53,12 +56,14 @@ const ConcatDialog = memo(({ setFileMeta(); setFileFormat(); setDetectedFileFormat(); + setOutFileName(); const fileMetaNew = await readFileMeta(firstPath); const fileFormatNew = await getSmarterOutFormat({ filePath: firstPath, fileMeta: fileMetaNew }); if (aborted) return; setFileMeta(fileMetaNew); setFileFormat(fileFormatNew); setDetectedFileFormat(fileFormatNew); + setUniqueSuffix(new Date().getTime()); })().catch(console.error); return () => { @@ -66,12 +71,23 @@ const ConcatDialog = memo(({ }; }, [firstPath, isShown, setDetectedFileFormat, setFileFormat]); + useEffect(() => { + if (fileFormat == null || firstPath == null) { + setOutFileName(); + return; + } + const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath: firstPath }); + setOutFileName(`${getFileBaseName(firstPath)}-merged-${uniqueSuffix}${ext}`); + }, [fileFormat, firstPath, isCustomFormatSelected, uniqueSuffix]); + const allFilesMeta = useMemo(() => { if (paths.length === 0) return undefined; const filtered = paths.map((path) => [path, allFilesMetaCache[path]]).filter(([, it]) => it); return filtered.length === paths.length ? filtered : undefined; }, [allFilesMetaCache, paths]); + const isOutFileNameValid = outFileName != null && outFileName.length > 0; + const problemsByFile = useMemo(() => { if (!allFilesMeta) return []; const allFilesMetaExceptFirstFile = allFilesMeta.slice(1); @@ -136,7 +152,7 @@ const ConcatDialog = memo(({ const onOutputFormatUserChange = useCallback((newFormat) => setFileFormat(newFormat), [setFileFormat]); - const onConcatClick = useCallback(() => onConcat({ paths, includeAllStreams, streams: fileMeta.streams, fileFormat, isCustomFormatSelected, clearBatchFilesAfterConcat }), [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, isCustomFormatSelected, onConcat, paths]); + const onConcatClick = useCallback(() => onConcat({ paths, includeAllStreams, streams: fileMeta.streams, fileName: outFileName, fileFormat, clearBatchFilesAfterConcat }), [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]); return ( <> @@ -147,16 +163,22 @@ const ConcatDialog = memo(({ topOffset="3vh" width="90vw" footer={( -
- setEnableReadFileMeta(e.target.checked)} label={t('Check files')} marginLeft={10} marginRight={10} /> - - {fileFormat && detectedFileFormat ? ( - - ) : ( - - )} - -
+ <> +
+ setEnableReadFileMeta(e.target.checked)} label={t('Check files')} marginLeft={10} marginRight={10} /> + + {fileFormat && detectedFileFormat ? ( + + ) : ( + + )} + +
+
+ {t('Output file name')}: + setOutFileName(e.target.value)} /> +
+ )} >
diff --git a/src/ffmpeg.js b/src/ffmpeg.js index bfd20ad0..79f72417 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -5,7 +5,7 @@ import i18n from 'i18next'; import Timecode from 'smpte-timecode'; import { pcmAudioCodecs, getMapStreamsArgs } from './util/streams'; -import { getOutPath, isDurationValid, getExtensionForFormat, isWindows, isMac, platform, arch } from './util'; +import { getSuffixedOutPath, isDurationValid, getExtensionForFormat, isWindows, isMac, platform, arch } from './util'; const execa = window.require('execa'); const { join } = window.require('path'); @@ -335,7 +335,7 @@ async function extractNonAttachmentStreams({ customOutDir, filePath, streams, en let streamArgs = []; await pMap(streams, async ({ index, codec, type, format: { format, ext } }) => { - const outPath = getOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); streamArgs = [ @@ -363,7 +363,7 @@ async function extractAttachmentStreams({ customOutDir, filePath, streams, enabl let streamArgs = []; await pMap(streams, async ({ index, codec_name: codec, codec_type: type }) => { const ext = codec || 'bin'; - const outPath = getOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `stream-${index}-${type}-${codec}.${ext}` }); if (!enableOverwriteOutput && await pathExists(outPath)) throw new RefuseOverwriteError(); streamArgs = [ diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js index d6219a97..f05feebe 100644 --- a/src/hooks/useFfmpegOperations.js +++ b/src/hooks/useFfmpegOperations.js @@ -3,7 +3,7 @@ import flatMap from 'lodash/flatMap'; import sum from 'lodash/sum'; import pMap from 'p-map'; -import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath } from '../util'; +import { getSuffixedOutPath, transferTimestamps, getOutFileExtension, getOutDir, deleteDispositionValue, getHtml5ifiedPath } from '../util'; import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments, readFileMeta, cutEncodeSmartPart, getExperimentalArgs, html5ify as ffmpegHtml5ify, getVideoTimescaleArgs, RefuseOverwriteError } from '../ffmpeg'; import { getMapStreamsArgs, getStreamIdsToCopy } from '../util/streams'; import { getSmartCutParams } from '../smartcut'; @@ -381,12 +381,12 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { const ext = getOutFileExtension({ isCustomFormatSelected: true, outFormat, filePath }); const smartCutMainPartOutPath = needsSmartCut - ? getOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-copy-${i}${ext}` }) + ? getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-copy-${i}${ext}` }) : getSegmentOutPath(); if (!needsSmartCut) await checkOverwrite(smartCutMainPartOutPath); - const smartCutEncodedPartOutPath = getOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` }); + const smartCutEncodedPartOutPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `smartcut-segment-encode-${i}${ext}` }); const smartCutSegmentsToConcat = [smartCutEncodedPartOutPath, smartCutMainPartOutPath]; @@ -429,7 +429,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { const autoConcatCutSegments = useCallback(async ({ customOutDir, isCustomFormatSelected, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, appendFfmpegCommandLog }) => { const ext = getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }); - const outPath = getOutPath({ customOutDir, filePath, nameSuffix: `cut-merged-${new Date().getTime()}${ext}` }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `cut-merged-${new Date().getTime()}${ext}` }); const outDir = getOutDir(customOutDir, filePath); const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames }); @@ -477,7 +477,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) { // 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 outPath = getOutPath({ customOutDir, filePath, nameSuffix: `reformatted${ext}` }); + const outPath = getSuffixedOutPath({ customOutDir, filePath, nameSuffix: `reformatted${ext}` }); const ffmpegArgs = [ '-hide_banner', diff --git a/src/util.js b/src/util.js index 63379bf2..27c59dca 100644 --- a/src/util.js +++ b/src/util.js @@ -27,9 +27,14 @@ export function getFileBaseName(filePath) { return parsed.name; } -export function getOutPath({ customOutDir, filePath, nameSuffix }) { +export function getOutPath({ customOutDir, filePath, fileName }) { if (!filePath) return undefined; - return join(getOutDir(customOutDir, filePath), `${getFileBaseName(filePath)}-${nameSuffix}`); + return join(getOutDir(customOutDir, filePath), fileName); +} + +export function getSuffixedOutPath({ customOutDir, filePath, nameSuffix }) { + if (!filePath) return undefined; + return getOutPath({ customOutDir, filePath, fileName: `${getFileBaseName(filePath)}-${nameSuffix}` }); } export async function havePermissionToReadFile(filePath) { @@ -246,7 +251,7 @@ export async function findExistingHtml5FriendlyFile(fp, cod) { export function getHtml5ifiedPath(cod, fp, type) { // See also inside ffmpegHtml5ify const ext = (isMac && ['slowest', 'slow', 'slow-audio'].includes(type)) ? 'mp4' : 'mkv'; - return getOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` }); + return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` }); } export async function deleteFiles({ toDelete, paths: { previewFilePath, sourceFilePath, projectFilePath } }) {