diff --git a/docs.md b/docs.md index ac8a5892..0e336432 100644 --- a/docs.md +++ b/docs.md @@ -60,25 +60,25 @@ When exporting segments as files, LosslessCut offers you the ability to specify The following variables are available in the template to customize the filenames: -| Avail. for cut+merge? | Variable | Type | Output | -| - | - | - | - | -| ✅ | `${FILENAME}` | `string` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`). -| ✅ | `${EXT}` | `string` | The extension of the file (e.g.: `.mp4`, `.mkv`). -| ✅ | `${EPOCH_MS}` | `number` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite. -| ✅ | `${EXPORT_COUNT}` | `number` | Number of exports done since last LosslessCut launch (starts at 1). -| ✅ | `${FILE_EXPORT_COUNT}` | `number` | Number of exports done since last file was opened (starts at 1). -| ✅ | `${SEG_LABEL}` | `string` / `string[]` | The label of the segment (e.g. `Getting Lunch`). In cut+merge mode, this will be an `Array`, and you can use e.g. this code to combine all labels with a comma between: `${SEG_LABEL.filter(label => label).join(',')}` -| | `${SEG_NUM}` | `string` | Segment index, padded string (e.g. `01`, `02` or `42`). -| | `${SEG_NUM_INT}` | `number` | Segment index, as an integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`. -| | `${SELECTED_SEG_NUM}` | `string` | Same as `SEG_NUM`, but it counts only selected segments. -| | `${SELECTED_SEG_NUM_INT}` | `number` | Same as `SEG_NUM_INT`, but it counts only selected segments. -| | `${SEG_SUFFIX}` | `string` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment index prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`). -| | `${CUT_FROM}` | `string` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`). -| | `${CUT_FROM_NUM}` | `number` | Same as `${CUT_FROM}`, but numeric, meaning it can be used with arithmetics. -| | `${CUT_TO}` | `string` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`). -| | `${CUT_TO_NUM}` | `number` | See `${CUT_FROM_NUM}`. -| | `${CUT_DURATION}` | `string` | The duration of the segment (`CUT_TO-CUT_FROM`) in `hh.mm.ss.sss` format (e.g. `00.00.28.000`). -| | `${SEG_TAGS.XX}` | `object` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will yield the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}` +| Avail. for merge files? | Avail. for cut+merge? | Variable | Type | Output | +| - | - | - | - | - | +| ✅ | ✅ | `${FILENAME}` | `string` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`). When merging files it's the *first* original file name. +| ✅ | ✅ | `${EXT}` | `string` | The extension of the file (e.g.: `.mp4`, `.mkv`). +| ✅ | ✅ | `${EPOCH_MS}` | `number` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite. +| ✅ | ✅ | `${EXPORT_COUNT}` | `number` | Number of exports done since last LosslessCut launch (starts at 1). +| | ✅ | `${FILE_EXPORT_COUNT}` | `number` | Number of exports done since last file was opened (starts at 1). +| ✅ | ✅ | `${SEG_LABEL}` | `string` / `string[]` | The label of the segment (e.g. `Getting Lunch`). In cut+merge mode, this will be an `Array`, and you can use e.g. this code to combine all labels with a comma between: `${SEG_LABEL.filter(label => label).join(',')}`. When merging files it's each original merged file's name. +| | | `${SEG_NUM}` | `string` | Segment index, padded string (e.g. `01`, `02` or `42`). +| | | `${SEG_NUM_INT}` | `number` | Segment index, as an integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`. +| | | `${SELECTED_SEG_NUM}` | `string` | Same as `SEG_NUM`, but it counts only selected segments. +| | | `${SELECTED_SEG_NUM_INT}` | `number` | Same as `SEG_NUM_INT`, but it counts only selected segments. +| | | `${SEG_SUFFIX}` | `string` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment index prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`). +| | | `${CUT_FROM}` | `string` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`). +| | | `${CUT_FROM_NUM}` | `number` | Same as `${CUT_FROM}`, but numeric, meaning it can be used with arithmetics. +| | | `${CUT_TO}` | `string` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`). +| | | `${CUT_TO_NUM}` | `number` | See `${CUT_FROM_NUM}`. +| | | `${CUT_DURATION}` | `string` | The duration of the segment (`CUT_TO-CUT_FROM`) in `hh.mm.ss.sss` format (e.g. `00.00.28.000`). +| | | `${SEG_TAGS.XX}` | `object` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will yield the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}` Your files must always include at least one unique identifer (such as `${SEG_NUM}` or `${CUT_FROM}`), and it should end in `${EXT}` (or else players might not recognise the files). For instance, to achieve a filename sequence of `Beach Trip - 1.mp4`, `Beach Trip - 2.mp4`, `Beach Trip - 3.mp4`, your format should read `${FILENAME} - ${SEG_NUM}${EXT}`. If your template gives at least two duplicate output file names, LosslessCut will revert to using the default template instead. diff --git a/src/main/configStore.ts b/src/main/configStore.ts index f6f79078..7f305d4a 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -127,6 +127,7 @@ const defaults: Config = { simpleMode: true, outSegTemplate: undefined, mergedFileTemplate: undefined, + mergedFilesTemplate: undefined, keyboardSeekAccFactor: 1.03, keyboardNormalSeekSpeed: 1, keyboardSeekSpeed2: 10, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 73ce662a..232626b3 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -15,7 +15,7 @@ import { SweetAlertOptions } from 'sweetalert2'; import useTimelineScroll from './hooks/useTimelineScroll'; import useUserSettingsRoot from './hooks/useUserSettingsRoot'; -import useFfmpegOperations, { OutputNotWritableError } from './hooks/useFfmpegOperations'; +import useFfmpegOperations, { maybeMkdirOutDir, OutputNotWritableError } from './hooks/useFfmpegOperations'; import useKeyframes from './hooks/useKeyframes'; import useWaveform from './hooks/useWaveform'; import useKeyboard from './hooks/useKeyboard'; @@ -83,7 +83,7 @@ import { askForOutDir, askForImportChapters, askForFileOpenAction, showCleanupFi import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { sortSegments, convertSegmentsToChaptersWithGaps, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers } from './segments'; -import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultCutMergedFileTemplate } from './util/outputNameTemplate'; +import { generateCutFileNames as generateCutFileNamesRaw, generateCutMergedFileNames as generateCutMergedFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultCutFileTemplate, defaultCutMergedFileTemplate, defaultMergedFileTemplate, GenerateMergedOutFileNamesParams, GeneratedOutFileNames } from './util/outputNameTemplate'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import BigWaveform from './components/BigWaveform'; @@ -173,8 +173,8 @@ function App() { const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); const allUserSettings = useUserSettingsRoot(); - const { captureFormat, customOutDir, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, outSegTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion } = allUserSettings.settings; - const { setCaptureFormat, setCustomOutDir, setKeyframeCut, setPlaybackVolume, setExportConfirmEnabled, setSimpleMode, setOutSegTemplate, setMergedFileTemplate, setOutFormatLocked, setSafeOutputFileName, setKeyBindings, resetKeyBindings, setStoreProjectInWorkingDir, setCleanupChoices, toggleDarkMode, setWaveformMode, setThumbnailsEnabled, setKeyframesEnabled, prefersReducedMotion } = allUserSettings; + const { captureFormat, customOutDir, keyframeCut, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, autoSaveProjectFile, wheelSensitivity, waveformHeight, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, segmentsToChapters, simpleMode, cutFileTemplate, cutMergedFileTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, safeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, mouseWheelFrameSeekModifierKey, mouseWheelKeyframeSeekModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, darkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, cutToAdjustmentFrames, waveformMode: waveformModePreference, thumbnailsEnabled, keyframesEnabled, reducedMotion } = allUserSettings.settings; + const { setCaptureFormat, setCustomOutDir, setKeyframeCut, setPlaybackVolume, setExportConfirmEnabled, setSimpleMode, setOutFormatLocked, setSafeOutputFileName, setKeyBindings, resetKeyBindings, setStoreProjectInWorkingDir, setCleanupChoices, toggleDarkMode, setWaveformMode, setThumbnailsEnabled, setKeyframesEnabled, prefersReducedMotion } = allUserSettings; const { withErrorHandling, handleError, genericError, setGenericError } = useErrorHandling(); @@ -201,8 +201,9 @@ function App() { ffmpegSetCustomFfPath(customFfPath); }, [customFfPath]); - const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate; - const mergedFileTemplateOrDefault = mergedFileTemplate || defaultCutMergedFileTemplate; + const cutFileTemplateOrDefault = cutFileTemplate || defaultCutFileTemplate; + const cutMergedFileTemplateOrDefault = cutMergedFileTemplate || defaultCutMergedFileTemplate; + const mergedFileTemplateOrDefault = mergedFileTemplate || defaultMergedFileTemplate; useEffect(() => { const l = language || fallbackLng; @@ -640,7 +641,6 @@ function App() { playbackModeRef.current = undefined; setFileDuration(undefined); cutSegmentsHistory.go(0); - setFileFormat(undefined); setDetectedFileFormat(undefined); setRotation(360); setProgress(undefined); @@ -665,7 +665,7 @@ function App() { setExportConfirmOpen(false); setOutputPlaybackRateState(1); setCurrentFileExportCount(0); - }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, playbackModeRef, cutSegmentsHistory, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setHideCompatPlayer, setOutputPlaybackRateState]); + }, [videoRef, setCommandedTime, setPlaybackRate, setPreviewFilePath, setUsingDummyVideo, setPlaying, playingRef, playbackModeRef, cutSegmentsHistory, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setSubtitlesByStreamId, setHideCompatPlayer, setOutputPlaybackRateState]); const showUnsupportedFileMessage = useCallback(() => { @@ -855,13 +855,27 @@ function App() { if (sendErrorReport) openSendConcatReportDialogWithState(err, reportState); }, [fileFormat, openSendConcatReportDialogWithState]); - const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, outFileName, clearBatchFilesAfterConcat }: { + const generateCutFileNames = useCallback(async (template: string) => { + invariant(fileFormat != null && outputDir != null && filePath != null); + return generateCutFileNamesRaw({ fileDuration, exportCount, currentFileExportCount, segmentsToExport, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }); + }, [currentFileExportCount, exportCount, fileDuration, fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]); + + const generateCutMergedFileNames = useCallback(async (template: string) => { + invariant(fileFormat != null && filePath != null); + return generateCutMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segLabels: segmentsToExport.map((seg) => seg.name ?? '') }); + }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]); + + const generateMergedFileNames = useCallback(async (params: GenerateMergedOutFileNamesParams) => ( + generateMergedFileNamesRaw({ template: params.template, isCustomFormatSelected, fileFormat: params.fileFormat, filePaths: params.filePaths, outputDir: params.outputDir, safeOutputFileName, maxLabelLength, exportCount, epochMs: params.epochMs }) + ), [exportCount, isCustomFormatSelected, maxLabelLength, safeOutputFileName]); + + const userConcatFiles = useCallback(async ({ paths, includeAllStreams, streams, fileFormat: outFormat, clearBatchFilesAfterConcat, generatedFileNames }: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], fileFormat: string, - outFileName: string, clearBatchFilesAfterConcat: boolean, + generatedFileNames: GeneratedOutFileNames, }) => { if (workingRef.current) return; try { @@ -871,11 +885,21 @@ function App() { const firstPath = paths[0]; if (!firstPath) return; - const newCustomOutDir = await ensureWritableOutDir({ inputPath: firstPath, outDir: customOutDir }); + const warnings = new Set(); + const notices = new Set(); + + const { fileNames, problems } = generatedFileNames; + if (problems.error != null) { + console.warn('Merged file name invalid, using default instead', fileNames[0]); + warnings.add(problems.error); + warnings.add(t('Fell back to default output file name')); + } - const outDir = getOutDir(newCustomOutDir, firstPath); + const outDir = getOutDir(customOutDir, firstPath); - const outPath = getOutPath({ customOutDir: newCustomOutDir, filePath: firstPath, fileName: outFileName }); + const [fileName] = fileNames; + invariant(fileName != null); + const outPath = getOutPath({ customOutDir, filePath: firstPath, fileName }); let chaptersFromSegments: Awaited>; if (segmentsToChapters) { @@ -888,21 +912,22 @@ function App() { // console.log('merge', paths); const metadataFromPath = paths[0]; invariant(metadataFromPath != null); - const { haveExcludedStreams } = await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments }); - const warnings: string[] = []; - const notices: string[] = []; + await maybeMkdirOutDir({ outputDir: outDir, fileOutPath: outPath }); + + const { haveExcludedStreams } = await concatFiles({ paths, outPath, outDir, outFormat, metadataFromPath, includeAllStreams, streams, ffmpegExperimental, onProgress: setProgress, preserveMovData, movFastStart, preserveMetadataOnMerge, chapters: chaptersFromSegments }); const outputSize = await readFileSize(outPath); // * 1.06; // testing:) const sizeCheckResult = checkFileSizes(inputSize, outputSize); - if (sizeCheckResult != null) warnings.push(sizeCheckResult); + if (sizeCheckResult != null) warnings.add(sizeCheckResult); if (clearBatchFilesAfterConcat) closeBatch(); - if (!includeAllStreams && haveExcludedStreams) notices.push(i18n.t('Some extra tracks have been discarded. You can change this option before merging.')); + if (!includeAllStreams && haveExcludedStreams) notices.add(i18n.t('Some extra tracks have been discarded. You can change this option before merging.')); + if (!enableOverwriteOutput) warnings.add(i18n.t('Overwrite output setting is disabled and some files might have been skipped.')); if (!hideAllNotifications) { showOsNotification(i18n.t('Merge finished')); - openConcatFinishedDialog({ filePath: outPath, notices, warnings }); + openConcatFinishedDialog({ filePath: outPath, notices: [...notices], warnings: [...warnings] }); } } catch (err) { if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; @@ -928,13 +953,13 @@ function App() { return; } - const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters, clearBatchFilesAfterConcat }; + const reportState = { includeAllStreams, streams, outFormat, segmentsToChapters, clearBatchFilesAfterConcat }; handleConcatFailed(err, reportState); } finally { setWorking(undefined); setProgress(undefined); } - }, [workingRef, setWorking, ensureWritableOutDir, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, hideAllNotifications, showOsNotification, openConcatFinishedDialog, handleConcatFailed]); + }, [workingRef, setWorking, customOutDir, segmentsToChapters, concatFiles, ffmpegExperimental, preserveMovData, movFastStart, preserveMetadataOnMerge, closeBatch, enableOverwriteOutput, hideAllNotifications, t, showOsNotification, openConcatFinishedDialog, handleConcatFailed]); const cleanupFiles = useCallback(async (cleanupChoices2: CleanupChoicesType) => { // Store paths before we reset state @@ -991,16 +1016,6 @@ function App() { } }, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]); - const generateOutSegFileNames = useCallback(async (template: string) => { - invariant(fileFormat != null && outputDir != null && filePath != null); - return generateOutSegFileNamesRaw({ fileDuration, exportCount, currentFileExportCount, segmentsToExport, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }); - }, [currentFileExportCount, exportCount, fileDuration, fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]); - - const generateMergedFileNames = useCallback(async (template: string) => { - invariant(fileFormat != null && filePath != null); - return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segmentsToExport }); - }, [currentFileExportCount, exportCount, fileFormat, filePath, isCustomFormatSelected, maxLabelLength, outputDir, safeOutputFileName, segmentsToExport]); - const closeExportConfirm = useCallback(() => setExportConfirmOpen(false), []); const willMerge = segmentsToExport.length > 1 && autoMerge; @@ -1037,16 +1052,16 @@ function App() { chaptersToAdd = isMatroska(fileFormat) ? sortedSegments : convertSegmentsToChaptersWithGaps(sortedSegments); } - console.log('outSegTemplateOrDefault', outSegTemplateOrDefault); + console.log('cutFileTemplate', cutFileTemplateOrDefault); const notices = new Set(); const warnings = new Set(); - const { fileNames: outSegFileNames, problems: outSegProblems } = await generateOutSegFileNames(outSegTemplateOrDefault); - if (outSegProblems.error != null) { - console.warn('Output segments file name invalid, using default instead', outSegFileNames); + const { fileNames: cutFileNames, problems: cutFilesProblems } = await generateCutFileNames(cutFileTemplateOrDefault); + if (cutFilesProblems.error != null) { + console.warn('Output segments file name invalid, using default instead', cutFileNames); + warnings.add(cutFilesProblems.error); warnings.add(t('Fell back to default output file name')); - warnings.add(outSegProblems.error); } // throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })(); @@ -1060,7 +1075,7 @@ function App() { allFilesMeta, keyframeCut, segments: segmentsToExport, - outSegFileNames, + cutFileNames, onProgress: setProgress, shortestFlag, ffmpegExperimental, @@ -1079,18 +1094,18 @@ function App() { let mergedOutFilePath: string | undefined; if (willMerge) { - console.log('mergedFileTemplateOrDefault', mergedFileTemplateOrDefault); + console.log('cutMergedFileTemplateOrDefault', cutMergedFileTemplateOrDefault); setProgress(0); setWorking({ text: i18n.t('Merging') }); const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined; - const { fileNames, problems } = await generateMergedFileNames(mergedFileTemplateOrDefault); + const { fileNames, problems } = await generateCutMergedFileNames(cutMergedFileTemplateOrDefault); if (problems.error != null) { console.warn('Merged file name invalid, using default instead', fileNames[0]); - warnings.add(t('Fell back to default output file name')); warnings.add(problems.error); + warnings.add(t('Fell back to default output file name')); } const [fileName] = fileNames; @@ -1181,7 +1196,7 @@ function App() { setWorking(undefined); setProgress(undefined); } - }, [filePath, numStreamsToCopy, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, fileDuration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, segmentsToExport, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, simpleMode, prefersReducedMotion, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, segmentsOrInverse.selected, t, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, concatCutSegments, autoDeleteMergedSegments, tryDeleteFiles, nonCopiedExtraStreams, extractStreams, showOsNotification, openCutFinishedDialog, handleExportFailed]); + }, [filePath, numStreamsToCopy, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, cutFileTemplateOrDefault, generateCutFileNames, cutMultiple, outputDir, customOutDir, fileFormat, fileDuration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, segmentsToExport, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, simpleMode, prefersReducedMotion, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, segmentsOrInverse.selected, t, cutMergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateCutMergedFileNames, concatCutSegments, autoDeleteMergedSegments, tryDeleteFiles, nonCopiedExtraStreams, extractStreams, showOsNotification, openCutFinishedDialog, handleExportFailed]); const onExportPress = useCallback(async () => { if (!filePath) return; @@ -2675,7 +2690,7 @@ function App() { {/* Dialogs */} - + @@ -2742,7 +2757,7 @@ function App() { - 0 && concatSheetOpen} onHide={() => setConcatSheetOpen(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} /> + 0 && concatSheetOpen} onHide={() => setConcatSheetOpen(false)} paths={batchFilePaths} mergedFileTemplate={mergedFileTemplateOrDefault} generateMergedFileNames={generateMergedFileNames} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} ensureWritableOutDir={ensureWritableOutDir} fileFormat={fileFormat} setFileFormat={setFileFormat} detectedFileFormat={detectedFileFormat} setDetectedFileFormat={setDetectedFileFormat} onOutputFormatUserChange={onOutputFormatUserChange} /> setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} /> diff --git a/src/renderer/src/TopMenu.tsx b/src/renderer/src/TopMenu.tsx index f01a9b9e..5bfe1b98 100644 --- a/src/renderer/src/TopMenu.tsx +++ b/src/renderer/src/TopMenu.tsx @@ -138,14 +138,12 @@ function TopMenu({ {customOutDir ? t('Working dir set') : t('Working dir unset')} - {filePath && ( - <> - {renderOutFmt(outFmtStyle)} + {renderOutFmt(outFmtStyle)} - {!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()} + {!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()} - - + {filePath && ( + )} {!simpleMode && ( diff --git a/src/renderer/src/components/ConcatSheet.tsx b/src/renderer/src/components/ConcatSheet.tsx index 54e0b1a6..fdc98bcb 100644 --- a/src/renderer/src/components/ConcatSheet.tsx +++ b/src/renderer/src/components/ConcatSheet.tsx @@ -1,4 +1,4 @@ -import { memo, useState, useCallback, useEffect, useMemo, CSSProperties } from 'react'; +import { memo, useState, useCallback, useEffect, useMemo, CSSProperties, Dispatch, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; import { AiOutlineMergeCells } from 'react-icons/ai'; import { FaQuestionCircle, FaExclamationTriangle, FaCog } from 'react-icons/fa'; @@ -7,24 +7,24 @@ import invariant from 'tiny-invariant'; import Checkbox from './Checkbox'; import { readFileMeta, getDefaultOutFormat, mapRecommendedDefaultFormat } from '../ffmpeg'; -import useFileFormatState from '../hooks/useFileFormatState'; import OutputFormatSelect from './OutputFormatSelect'; import useUserSettings from '../hooks/useUserSettings'; import { isMov } from '../util/streams'; -import { getOutDir, getOutFileExtension } from '../util'; +import { getOutDir } from '../util'; import { FFprobeChapter, FFprobeFormat, FFprobeStream } from '../../../../ffprobe'; -import TextInput from './TextInput'; import Button, { DialogButton } from './Button'; -import { defaultMergedFileTemplate, generateMergedFileNames, maxFileNameLength } from '../util/outputNameTemplate'; +import { defaultMergedFileTemplate, GeneratedOutFileNames, GenerateMergedOutFileNames } from '../util/outputNameTemplate'; import { primaryColor } from '../colors'; import ExportSheet from './ExportSheet'; import * as Dialog from './Dialog'; +import FileNameTemplateEditor from './FileNameTemplateEditor'; +import { EnsureWritableOutDir } from '../hooks/useDirectoryAccess'; const { basename } = window.require('path'); const rowStyle: CSSProperties = { - fontSize: '1em', margin: '4px 0px', overflowY: 'auto', whiteSpace: 'nowrap', + fontSize: '1em', margin: '.3em 0', overflowY: 'auto', whiteSpace: 'nowrap', }; function Alert({ text }: { text: string }) { @@ -33,29 +33,31 @@ function Alert({ text }: { text: string }) { ); } -function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, exportCount, maxLabelLength }: { +function ConcatSheet({ isShown, onHide, paths, mergedFileTemplate, generateMergedFileNames, onConcat, alwaysConcatMultipleFiles, setAlwaysConcatMultipleFiles, ensureWritableOutDir, fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, onOutputFormatUserChange }: { isShown: boolean, onHide: () => void, paths: string[], - onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], outFileName: string, fileFormat: string, clearBatchFilesAfterConcat: boolean }) => Promise, + mergedFileTemplate: string, + generateMergedFileNames: GenerateMergedOutFileNames, + onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], fileFormat: string, clearBatchFilesAfterConcat: boolean, generatedFileNames: GeneratedOutFileNames }) => Promise, alwaysConcatMultipleFiles: boolean, setAlwaysConcatMultipleFiles: (a: boolean) => void, - exportCount: number, - maxLabelLength: number, + ensureWritableOutDir: EnsureWritableOutDir, + fileFormat: string | undefined, + setFileFormat: Dispatch>, + detectedFileFormat: string | undefined, + setDetectedFileFormat: Dispatch>, + onOutputFormatUserChange: (newFormat: string) => void, }) { const { t } = useTranslation(); - const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, safeOutputFileName, customOutDir, simpleMode } = useUserSettings(); + const { preserveMovData, setPreserveMovData, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, customOutDir, simpleMode, setMergedFileTemplate, outFormatLocked } = useUserSettings(); const [includeAllStreams, setIncludeAllStreams] = useState(false); const [fileMeta, setFileMeta] = useState<{ format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>(); const [allFilesMetaCache, setAllFilesMetaCache] = useState>({}); 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(); + const [uniqueSuffix, setUniqueSuffix] = useState(Date.now()); const firstPath = useMemo(() => { if (paths.length === 0) return undefined; @@ -71,49 +73,31 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil setFileMeta(undefined); setFileFormat(undefined); setDetectedFileFormat(undefined); - setOutFileName(undefined); invariant(firstPath != null); const fileMetaNew = await readFileMeta(firstPath); const fileFormatNew = await getDefaultOutFormat({ filePath: firstPath, fileMeta: fileMetaNew }); if (aborted) return; setFileMeta(fileMetaNew); setDetectedFileFormat(fileFormatNew); - setFileFormat(mapRecommendedDefaultFormat({ sourceFormat: fileFormatNew, streams: fileMetaNew.streams }).format); + if (outFormatLocked) { + setFileFormat(outFormatLocked); + } else { + setFileFormat(mapRecommendedDefaultFormat({ sourceFormat: fileFormatNew, streams: fileMetaNew.streams }).format); + } setUniqueSuffix(Date.now()); })().catch(console.error); return () => { aborted = true; }; - }, [firstPath, isShown, setDetectedFileFormat, setFileFormat]); + }, [firstPath, isShown, outFormatLocked, setDetectedFileFormat, setFileFormat]); - useEffect(() => { - if (fileFormat == null || firstPath == null || uniqueSuffix == null) { - setOutFileName(undefined); - return; - } - const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath: firstPath }); + const generateFileNames = useCallback(async (template: string) => { + invariant(firstPath != null && fileFormat != null); const outputDir = getOutDir(customOutDir, firstPath); - setOutFileName((existingOutputName) => { - // here we only generate the file name the first time. Then the user can edit it manually as they please in the text input field. - // todo allow user to edit template instead of this "hack" - if (existingOutputName == null) { - (async () => { - const generated = await generateMergedFileNames({ template: defaultMergedFileTemplate, isCustomFormatSelected, fileFormat, filePath: firstPath, outputDir, safeOutputFileName, maxLabelLength, epochMs: uniqueSuffix, exportCount }); - // todo show to user more errors? - const [fileName] = generated.fileNames; - invariant(fileName != null); - setOutFileName(fileName); - })(); - return existingOutputName; // async later (above) - } - - // in case the user has chosen a different output format: - // make sure the last (optional) .* is replaced by .ext` - return existingOutputName.replace(/(\.[^.]*)?$/, ext); - }); - }, [customOutDir, exportCount, fileFormat, firstPath, isCustomFormatSelected, maxLabelLength, safeOutputFileName, uniqueSuffix]); + return generateMergedFileNames({ template, filePaths: paths, fileFormat, outputDir, epochMs: uniqueSuffix }); + }, [customOutDir, fileFormat, firstPath, generateMergedFileNames, paths, uniqueSuffix]); const allFilesMeta = useMemo(() => { if (paths.length === 0) return undefined; @@ -121,9 +105,6 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil return filtered.length === paths.length ? filtered : undefined; }, [allFilesMetaCache, paths]); - const isOutFileNameTooLong = outFileName != null && outFileName.length > maxFileNameLength; - const isOutFileNameValid = outFileName != null && outFileName.length > 0 && !isOutFileNameTooLong; - const problemsByFile = useMemo(() => { if (!allFilesMeta) return {}; const allFilesMetaExceptFirstFile = allFilesMeta.slice(1); @@ -177,13 +158,23 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil }; }, [allFilesMetaCache, enableReadFileMeta, isShown, paths]); - const onOutputFormatUserChange = useCallback((newFormat: string) => setFileFormat(newFormat), [setFileFormat]); - - const onConcatClick = useCallback(() => { - invariant(outFileName != null); + const onConcatClick = useCallback(async () => { invariant(fileFormat != null); - onConcat({ paths, includeAllStreams, streams: fileMeta!.streams, outFileName, fileFormat, clearBatchFilesAfterConcat }); - }, [clearBatchFilesAfterConcat, fileFormat, fileMeta, includeAllStreams, onConcat, outFileName, paths]); + invariant(firstPath != null); + + // need to ensure the output dir is writable, because the user might not yet have opened a file, and so MAS might not yet have access to write the dir + const newCustomOutDir = await ensureWritableOutDir({ inputPath: firstPath, outDir: customOutDir }); + if (newCustomOutDir !== customOutDir) { + // throw user back to dialog because now things might have changed (which could affect overwriting files etc!) + return; + } + + const outputDir = getOutDir(customOutDir, firstPath); + + const generatedFileNames = await generateMergedFileNames({ template: mergedFileTemplate, filePaths: paths, fileFormat, outputDir, epochMs: uniqueSuffix }); + + await onConcat({ paths, includeAllStreams, streams: fileMeta!.streams, fileFormat, clearBatchFilesAfterConcat, generatedFileNames }); + }, [clearBatchFilesAfterConcat, customOutDir, ensureWritableOutDir, fileFormat, fileMeta, firstPath, generateMergedFileNames, includeAllStreams, mergedFileTemplate, onConcat, paths, uniqueSuffix]); return ( ( - )} @@ -208,11 +199,13 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil
{`${index + 1}.`} {basename(path)} + {!allFilesMetaCache[path] && } + {problemsByFile[path] && ( - + @@ -238,58 +231,58 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil
setEnableReadFileMeta(!!checked)} label={t('Check compatibility')} /> - + + + + -
{t('Output container format:')}
+ + + + {t('Merge options')} - {fileFormat && detectedFileFormat && ( - - )} + setIncludeAllStreams(checked === true)} label={`${t('Include all tracks?')} - ${t('If this is checked, all audio/video/subtitle/data tracks will be included. This may not always work for all file types. If not checked, only default streams will be included.')}`} /> -
{t('Output file name')}:
- setOutFileName(e.target.value)} /> + setPreserveMetadataOnMerge(checked === true)} label={t('Preserve original metadata when merging? (slow)')} /> - {isOutFileNameTooLong && ( - - )} - {enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && ( - - )} - {!enableReadFileMeta && ( - - )} -
+ {fileFormat != null && isMov(fileFormat) && setPreserveMovData(checked === true)} label={t('Preserve all MP4/MOV metadata?')} />} - - - - - {t('Merge options')} + setSegmentsToChapters(checked === true)} label={t('Create chapters from merged segments? (slow)')} /> - setIncludeAllStreams(checked === true)} label={`${t('Include all tracks?')} - ${t('If this is checked, all audio/video/subtitle/data tracks will be included. This may not always work for all file types. If not checked, only default streams will be included.')}`} /> + setAlwaysConcatMultipleFiles(checked === true)} label={t('Always open this dialog when opening multiple files')} /> - setPreserveMetadataOnMerge(checked === true)} label={t('Preserve original metadata when merging? (slow)')} /> + setClearBatchFilesAfterConcat(checked === true)} label={t('Clear batch file list after merge')} /> - {fileFormat != null && isMov(fileFormat) && setPreserveMovData(checked === true)} label={t('Preserve all MP4/MOV metadata?')} />} +

{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}

- setSegmentsToChapters(checked === true)} label={t('Create chapters from merged segments? (slow)')} /> + + + {t('Done')} + + - setAlwaysConcatMultipleFiles(checked === true)} label={t('Always open this dialog when opening multiple files')} /> + +
+
+
- setClearBatchFilesAfterConcat(checked === true)} label={t('Clear batch file list after merge')} /> +
{t('Output container format:')}
-

{t('Note that also other settings from the normal export dialog apply to this merge function. For more information about all options, see the export dialog.')}

+ {fileFormat != null && detectedFileFormat != null && ( + + )} - - - {t('Done')} - - + {fileFormat != null && ( + + )} - - -
-
+ {enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && ( + + )} + {!enableReadFileMeta && ( + + )} +
); } diff --git a/src/renderer/src/components/ExportConfirm.tsx b/src/renderer/src/components/ExportConfirm.tsx index a8e46946..75cc483f 100644 --- a/src/renderer/src/components/ExportConfirm.tsx +++ b/src/renderer/src/components/ExportConfirm.tsx @@ -19,7 +19,7 @@ import { isMov as ffmpegIsMov } from '../util/streams'; import useUserSettings from '../hooks/useUserSettings'; import styles from './ExportConfirm.module.css'; import { SegmentToExport } from '../types'; -import { defaultMergedFileTemplate, defaultOutSegTemplate, GenerateOutFileNames } from '../util/outputNameTemplate'; +import { defaultCutFileTemplate, defaultCutMergedFileTemplate, GenerateOutFileNames } from '../util/outputNameTemplate'; import { FFprobeStream } from '../../../../ffprobe'; import { AvoidNegativeTs, PreserveMetadata } from '../../../../types'; import TextInput from './TextInput'; @@ -85,12 +85,10 @@ function ExportConfirm({ numStreamsTotal, numStreamsToCopy, onShowStreamsSelectorClick, - outSegTemplate, - setOutSegTemplate, - mergedFileTemplate, - setMergedFileTemplate, - generateOutSegFileNames, - generateMergedFileNames, + cutFileTemplate, + cutMergedFileTemplate, + generateCutFileNames, + generateCutMergedFileNames, currentSegIndexSafe, segmentsOrInverse, mainCopiedThumbnailStreams, @@ -114,12 +112,10 @@ function ExportConfirm({ numStreamsTotal: number, numStreamsToCopy: number, onShowStreamsSelectorClick: () => void, - outSegTemplate: string, - setOutSegTemplate: (a: string) => void, - mergedFileTemplate: string, - setMergedFileTemplate: (a: string) => void, - generateOutSegFileNames: GenerateOutFileNames, - generateMergedFileNames: GenerateOutFileNames, + cutFileTemplate: string, + cutMergedFileTemplate: string, + generateCutFileNames: GenerateOutFileNames, + generateCutMergedFileNames: GenerateOutFileNames, currentSegIndexSafe: number, segmentsOrInverse: UseSegments['segmentsOrInverse'], mainCopiedThumbnailStreams: FFprobeStream[], @@ -133,7 +129,7 @@ function ExportConfirm({ }) { const { t } = useTranslation(); - const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, setPreserveMovData, preserveMetadata, setPreserveMetadata, preserveChapters, setPreserveChapters, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames, cutToAdjustmentFrames, setCutToAdjustmentFrames } = useUserSettings(); + const { changeOutDir, keyframeCut, toggleKeyframeCut, preserveMovData, setPreserveMovData, preserveMetadata, setPreserveMetadata, preserveChapters, setPreserveChapters, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoDeleteMergedSegments, exportConfirmEnabled, toggleExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, enableSmartCut, setEnableSmartCut, effectiveExportMode, enableOverwriteOutput, setEnableOverwriteOutput, ffmpegExperimental, setFfmpegExperimental, cutFromAdjustmentFrames, setCutFromAdjustmentFrames, cutToAdjustmentFrames, setCutToAdjustmentFrames, setCutFileTemplate, setCutMergedFileTemplate } = useUserSettings(); const togglePreserveChapters = useCallback(() => setPreserveChapters((val) => !val), [setPreserveChapters]); const togglePreserveMovData = useCallback(() => setPreserveMovData((val) => !val), [setPreserveMovData]); @@ -239,11 +235,11 @@ function ExportConfirm({ showHelpText({ text: i18n.t('When merging, do you want to preserve metadata from your original file? NOTE: This may dramatically increase processing time') }); }, [showHelpText]); - const onOutSegTemplateHelpPress = useCallback(() => { + const onCutFileTemplateHelpPress = useCallback(() => { showHelpText({ text: i18n.t('You can customize the file name of the output segment(s) using special variables.', { count: segmentsToExport.length }) }); }, [segmentsToExport.length, showHelpText]); - const onMergedFileTemplateHelpPress = useCallback(() => { + const onCutMergedFileTemplateHelpPress = useCallback(() => { showHelpText({ text: i18n.t('You can customize the file name of the merged file using special variables.') }); }, [showHelpText]); @@ -377,10 +373,10 @@ function ExportConfirm({ {canEditSegTemplate && ( - + - + )} @@ -388,10 +384,10 @@ function ExportConfirm({ {willMerge && ( - + - + )} diff --git a/src/renderer/src/components/FileNameTemplateEditor.tsx b/src/renderer/src/components/FileNameTemplateEditor.tsx index 7b903b13..29b54428 100644 --- a/src/renderer/src/components/FileNameTemplateEditor.tsx +++ b/src/renderer/src/components/FileNameTemplateEditor.tsx @@ -7,7 +7,7 @@ import { motion, AnimatePresence } from 'framer-motion'; import { FaCheck, FaEdit, FaExclamationTriangle, FaFile, FaUndo } from 'react-icons/fa'; import HighlightedText from './HighlightedText'; -import { segNumVariable, segSuffixVariable, GenerateOutFileNames, extVariable, segTagsVariable, segNumIntVariable, selectedSegNumVariable, selectedSegNumIntVariable } from '../util/outputNameTemplate'; +import { segNumVariable, segSuffixVariable, GenerateOutFileNames, extVariable, segTagsVariable, segNumIntVariable, selectedSegNumVariable, selectedSegNumIntVariable, GeneratedOutFileNames } from '../util/outputNameTemplate'; import useUserSettings from '../hooks/useUserSettings'; import Switch from './Switch'; import Select from './Select'; @@ -31,21 +31,19 @@ function FileNameTemplateEditor(opts: { generateFileNames: GenerateOutFileNames, } & ({ currentSegIndexSafe: number, - mergeMode?: false + mode: 'separate' } | { - mergeMode: true + mode: 'merge-segments' | 'merge-files' })) { - const { template: templateIn, setTemplate, defaultTemplate, generateFileNames, mergeMode } = opts; + const { template: templateIn, setTemplate, defaultTemplate, generateFileNames, mode } = opts; const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding, simpleMode } = useUserSettings(); const [text, setText] = useState(templateIn); const [debouncedText] = useDebounce(text, 500); - const [validText, setValidText] = useState(); - const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false }); - const [fileNames, setFileNames] = useState(); + const [generated, setGenerated] = useState(); - const haveImportantMessage = problems.error != null || problems.sameAsInputFileNameWarning; + const haveImportantMessage = generated != null && (generated.problems.error != null || generated.problems.sameAsInputFileNameWarning); const [shown, setShown] = useState(haveImportantMessage); useEffect(() => { // if an important message appears, make sure we don't auto-close after it's resolved @@ -71,45 +69,47 @@ function FileNameTemplateEditor(opts: { (async () => { try { // console.time('generateFileNames') - const outSegs = await generateFileNames(debouncedText); - // console.timeEnd('generateOutSegFileNames') + const newGenerated = await generateFileNames(debouncedText); + // console.timeEnd('generateCutFileNames') if (abortController.signal.aborted) return; - setFileNames(outSegs.originalFileNames ?? outSegs.fileNames); - setProblems(outSegs.problems); - setValidText(outSegs.problems.error == null ? debouncedText : undefined); + setGenerated(newGenerated); } catch (err) { - console.error(err); - setValidText(undefined); - setProblems({ error: err instanceof Error ? err.message : String(err) }); + console.error(err); // shouldn't really happen } })(); return () => abortController.abort(); }, [debouncedText, generateFileNames, t]); - const availableVariables = useMemo(() => (mergeMode - ? ['FILENAME', extVariable, 'EPOCH_MS', 'EXPORT_COUNT', 'FILE_EXPORT_COUNT', 'SEG_LABEL'] - : [ - 'FILENAME', extVariable, 'EPOCH_MS', 'EXPORT_COUNT', 'FILE_EXPORT_COUNT', 'SEG_LABEL', - 'CUT_FROM', - ...(!simpleMode ? ['CUT_FROM_NUM'] : []), - 'CUT_TO', - ...(!simpleMode ? ['CUT_TO_NUM'] : []), - 'CUT_DURATION', - segNumVariable, - ...(!simpleMode ? [segNumIntVariable] : []), - selectedSegNumVariable, - ...(!simpleMode ? [selectedSegNumIntVariable] : []), - segSuffixVariable, segTagsExample, - ] - ), [mergeMode, simpleMode]); - - // eslint-disable-next-line no-template-curly-in-string - const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted); + const availableVariables = useMemo(() => { + const common = ['FILENAME', extVariable, 'EPOCH_MS', 'SEG_LABEL', 'EXPORT_COUNT']; + if (mode === 'merge-segments') { + return [...common, 'FILE_EXPORT_COUNT']; + } + if (mode === 'separate') { + return [ + ...common, + 'CUT_FROM', + ...(!simpleMode ? ['CUT_FROM_NUM'] : []), + 'CUT_TO', + ...(!simpleMode ? ['CUT_TO_NUM'] : []), + 'CUT_DURATION', + segNumVariable, + ...(!simpleMode ? [segNumIntVariable] : []), + selectedSegNumVariable, + ...(!simpleMode ? [selectedSegNumIntVariable] : []), + segSuffixVariable, segTagsExample, + ]; + } + // merge-files + return common; + }, [mode, simpleMode]); + + const isMissingExtension = !debouncedText.endsWith(extVariableFormatted); useEffect(() => { - if (validText != null) setTemplate(validText); - }, [validText, setTemplate]); + setTemplate(debouncedText); + }, [debouncedText, setTemplate]); const reset = useCallback(() => { setTemplate(defaultTemplate); @@ -117,8 +117,8 @@ function FileNameTemplateEditor(opts: { }, [defaultTemplate, setTemplate]); const onHideClick = useCallback(() => { - if (problems.error == null) setShown(false); - }, [problems.error]); + if (generated != null && generated.problems.error == null) setShown(false); + }, [generated]); const onShowClick = useCallback(() => { if (!shown) setShown(true); @@ -138,18 +138,34 @@ function FileNameTemplateEditor(opts: { setText(newValue); }, [text]); + function formatCurrentSegFileOrFirst(names: string[]) { + if (mode === 'separate') { + const { currentSegIndexSafe } = opts; + const fileName = names[currentSegIndexSafe]; + if (fileName != null) { + return fileName; + } + } + return names[0] ?? '-'; + } + return ( <> - {fileNames != null && ( -
{(mergeMode ? t('Merged output file name:') : t('Output name(s):', { count: fileNames.length }))}
+ {generated != null && ( +
{ + (mode === 'merge-files' || mode === 'merge-segments') + ? t('Merged output file name:') + : t('Output name(s):', { count: generated.fileNames.length }) + } +
)} - {fileNames != null && ( + {generated != null && (
- - {/* eslint-disable-next-line react/destructuring-assignment */} - {('currentSegIndexSafe' in opts ? fileNames[opts.currentSegIndexSafe] : undefined) || fileNames[0] || '-'} + + {generated.originalFileNames != null && formatCurrentSegFileOrFirst(generated.fileNames)} + {formatCurrentSegFileOrFirst(generated.originalFileNames ?? generated.fileNames)} {!needToShow && }
@@ -168,7 +184,7 @@ function FileNameTemplateEditor(opts: {
- {!mergeMode && fileNames != null && ( + {generated != null && generated.fileNames.length > 1 && ( @@ -177,10 +193,10 @@ function FileNameTemplateEditor(opts: { - {t('Resulting segment file names', { count: fileNames.length })} + {t('Resulting segment file names', { count: generated.fileNames.length })}
- {fileNames.map((f) =>
{f}
)} + {generated.fileNames.map((f) =>
{f}
)}
@@ -223,26 +239,28 @@ function FileNameTemplateEditor(opts: { )} - {problems.error != null ? ( + {generated?.problems.error != null ? (
- {problems.error} + {generated.problems.error}
) : ( - <> - {problems.sameAsInputFileNameWarning && ( -
- - {i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')} -
- )} + generated != null && ( + <> + {generated.problems.sameAsInputFileNameWarning && ( +
+ + {i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')} +
+ )} - {isMissingExtension && ( -
- - {i18n.t('The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.', { ext: extVariableFormatted })} -
- )} - + {isMissingExtension && ( +
+ + {i18n.t('The file name template is missing {{ext}} and will result in a file without the suggested extension. This may result in an unplayable output file.', { ext: extVariableFormatted })} +
+ )} + + ) )} diff --git a/src/renderer/src/components/GenericDialog.tsx b/src/renderer/src/components/GenericDialog.tsx index f9f63201..9ac494ad 100644 --- a/src/renderer/src/components/GenericDialog.tsx +++ b/src/renderer/src/components/GenericDialog.tsx @@ -174,10 +174,10 @@ export function useDialog() { children: ( } iconColor={hasWarnings ? 'var(--orange-8)' : 'var(--green-11)'} style={{ fontWeight: 'bold' }}>{hasWarnings ? t('Export finished with warning(s)', { count: warnings.length }) : t('Export is done!')} + }>{t('Please test the output file in your desired player/editor before you delete the source file.')} - ), }); @@ -192,10 +192,10 @@ export function useDialog() { children: ( } iconColor={hasWarnings ? 'warning' : 'success'} style={{ fontWeight: 'bold' }}>{hasWarnings ? t('Files merged with warning(s)', { count: warnings.length }) : t('Files merged!')} + }>{t('Please test the output files in your desired player/editor before you delete the source files.')} - ), }); diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index 8a823a19..4880db1b 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -468,6 +468,7 @@ const DifferentFileSuggestion = () =>
  • Try with a Different file
  • See Help menu
  • ; const ErrorReportSuggestion = () =>
  • If nothing helps, you can send an Error report
  • ; +// todo Dialog component export async function showExportFailedDialog({ fileFormat, safeOutputFileName }: { fileFormat: string | undefined, safeOutputFileName: boolean }) { const html = (
    @@ -490,6 +491,7 @@ export async function showExportFailedDialog({ fileFormat, safeOutputFileName }: return value; } +// todo Dialog component export async function showConcatFailedDialog({ fileFormat }: { fileFormat: string | undefined }) { const html = (
    diff --git a/src/renderer/src/hooks/useDirectoryAccess.ts b/src/renderer/src/hooks/useDirectoryAccess.ts index c2e6bcfc..887bd8ac 100644 --- a/src/renderer/src/hooks/useDirectoryAccess.ts +++ b/src/renderer/src/hooks/useDirectoryAccess.ts @@ -21,7 +21,7 @@ const simulateMasBuild = false; const masMode = isMasBuild || simulateMasBuild; -export default ({ setCustomOutDir }: { setCustomOutDir: (a: string | undefined) => void }) => { +export default function useDirectoryAccess({ setCustomOutDir }: { setCustomOutDir: (a: string | undefined) => void }) { const ensureAccessToSourceDir = useCallback(async (inputPath: string) => { // Called if we need to read/write to the source file's directory (probably to read/write the project file) const inputFileDir = getFileDir(inputPath); @@ -93,4 +93,6 @@ export default ({ setCustomOutDir }: { setCustomOutDir: (a: string | undefined) ensureAccessToSourceDir, ensureWritableOutDir, }; -}; +} + +export type EnsureWritableOutDir = ReturnType['ensureWritableOutDir']; diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index dad91234..edf868d5 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -77,6 +77,14 @@ async function pathExists(path: string) { } } +export async function maybeMkdirOutDir({ outputDir, fileOutPath }: { outputDir: string, fileOutPath: string }) { + // cutFileNames might contain slashes and therefore might have a subdir(tree) that we need to mkdir + // https://github.com/mifi/lossless-cut/issues/1532 + const actualOutputDir = dirname(fileOutPath); + if (actualOutputDir !== outputDir) await mkdir(actualOutputDir, { recursive: true }); +} + + function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, isEncoding, lossyMode, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, cutToAdjustmentFrames, appendLastCommandsLog, encCustomBitrate, appendFfmpegCommandLog }: { filePath: string | undefined, treatInputFileModifiedTimeAsStart: boolean, @@ -482,12 +490,12 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea }, [appendFfmpegCommandLog, filePath]); const cutMultiple = useCallback(async ({ - outputDir, customOutDir, segments: segmentsIn, outSegFileNames, fileDuration, rotation, detectedFps, onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, chapters, + outputDir, customOutDir, segments: segmentsIn, cutFileNames, fileDuration, rotation, detectedFps, onProgress: onTotalProgress, keyframeCut, copyFileStreams, allFilesMeta, outFormat, shortestFlag, ffmpegExperimental, preserveMetadata, preserveMetadataOnMerge, preserveMovData, preserveChapters, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, chapters, }: { outputDir: string, customOutDir: string | undefined, segments: SegmentToExport[], - outSegFileNames: string[], + cutFileNames: string[], fileDuration: number | undefined, rotation: number | undefined, detectedFps: number | undefined, @@ -529,14 +537,11 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const onProgress = (progress: number) => onSingleProgress(i, progress / 2); const onConcatProgress = (progress: number) => onSingleProgress(i, (1 + progress) / 2); - const finalOutPath = join(outputDir, outSegFileNames[i]!); + const finalOutPath = join(outputDir, cutFileNames[i]!); if (await shouldSkipExistingFile(finalOutPath)) return { path: finalOutPath, created: false }; - // outSegFileNames might contain slashes and therefore might have a subdir(tree) that we need to mkdir - // https://github.com/mifi/lossless-cut/issues/1532 - const actualOutputDir = dirname(finalOutPath); - if (actualOutputDir !== outputDir) await mkdir(actualOutputDir, { recursive: true }); + await maybeMkdirOutDir({ outputDir, fileOutPath: finalOutPath }); if (!isEncoding) { // simple lossless cut @@ -649,7 +654,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea } finally { if (chaptersPath) await tryDeleteFiles([chaptersPath]); } - }, [shouldSkipExistingFile, isEncoding, filePath, losslessCutSingle, cutEncodeSmartPart, encCustomBitrate, lossyMode, concatFiles]); + }, [shouldSkipExistingFile, isEncoding, filePath, lossyMode, losslessCutSingle, cutEncodeSmartPart, encCustomBitrate, concatFiles]); const concatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }: { customOutDir: string | undefined, diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index 4e310df7..8501fb75 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -109,10 +109,12 @@ export default function useUserSettingsRoot() { useEffect(() => safeSetConfig({ segmentsToChapters }), [segmentsToChapters]); const [simpleMode, setSimpleMode] = useState(safeGetConfigInitial('simpleMode')); useEffect(() => safeSetConfig({ simpleMode }), [simpleMode]); - const [outSegTemplate, setOutSegTemplate] = useState(safeGetConfigInitial('outSegTemplate')); - useEffect(() => safeSetConfig({ outSegTemplate }), [outSegTemplate]); - const [mergedFileTemplate, setMergedFileTemplate] = useState(safeGetConfigInitial('mergedFileTemplate')); - useEffect(() => safeSetConfig({ mergedFileTemplate }), [mergedFileTemplate]); + const [cutFileTemplate, setCutFileTemplate] = useState(safeGetConfigInitial('outSegTemplate')); + useEffect(() => safeSetConfig({ outSegTemplate: cutFileTemplate }), [cutFileTemplate]); + const [cutMergedFileTemplate, setCutMergedFileTemplate] = useState(safeGetConfigInitial('mergedFileTemplate')); + useEffect(() => safeSetConfig({ mergedFileTemplate: cutMergedFileTemplate }), [cutMergedFileTemplate]); + const [mergedFileTemplate, setMergedFileTemplate] = useState(safeGetConfigInitial('mergedFilesTemplate')); + useEffect(() => safeSetConfig({ mergedFilesTemplate: mergedFileTemplate }), [mergedFileTemplate]); const [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor')); useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]); const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed')); @@ -246,7 +248,8 @@ export default function useUserSettingsRoot() { exportConfirmEnabled, segmentsToChapters, simpleMode, - outSegTemplate, + cutFileTemplate, + cutMergedFileTemplate, mergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, @@ -318,7 +321,8 @@ export default function useUserSettingsRoot() { setExportConfirmEnabled, setSegmentsToChapters, setSimpleMode, - setOutSegTemplate, + setCutFileTemplate, + setCutMergedFileTemplate, setMergedFileTemplate, setKeyboardSeekAccFactor, setKeyboardNormalSeekSpeed, diff --git a/src/renderer/src/util/outputNameTemplate.ts b/src/renderer/src/util/outputNameTemplate.ts index 9d6ed2af..36cb6348 100644 --- a/src/renderer/src/util/outputNameTemplate.ts +++ b/src/renderer/src/util/outputNameTemplate.ts @@ -2,6 +2,7 @@ import i18n from 'i18next'; import { PlatformPath } from 'node:path'; import pMap from 'p-map'; import max from 'lodash/max'; +import invariant from 'tiny-invariant'; import { isMac, isWindows, hasDuplicates, filenamify, getOutFileExtension } from '../util'; import isDev from '../isDev'; @@ -24,6 +25,25 @@ export const maxFileNameLength = 200; const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path'); +export interface GeneratedOutFileNames { + fileNames: string[], + originalFileNames?: string[] | undefined, + problems: { + error: string | undefined; + sameAsInputFileNameWarning?: boolean; + }, +} + +export type GenerateOutFileNames = (template: string) => Promise; + +export interface GenerateMergedOutFileNamesParams { + template: string; + filePaths: string[]; + fileFormat: string; + outputDir: string; + epochMs: number; +} + function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName }: { fileNames: string[], filePath: string, @@ -31,6 +51,7 @@ function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileNam safeOutputFileName: boolean, }) { let error: string | undefined; + let sameAsInputFileNameWarning = false; for (const fileName of fileNames) { @@ -108,7 +129,7 @@ function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileNam // This is used as a fallback and so it has to always generate unique file names // eslint-disable-next-line no-template-curly-in-string -export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}'; +export const defaultCutFileTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}'; // eslint-disable-next-line no-template-curly-in-string export const defaultCutMergedFileTemplate = '${FILENAME}-cut-merged-${EPOCH_MS}${EXT}'; // eslint-disable-next-line no-template-curly-in-string @@ -195,18 +216,29 @@ async function generateWithFallback({ generate, desiredTemplate, defaultTemplate // however we disable this when the user has chosen to (safeOutputFileName === false) const sanitizeName = (name: string, safe: boolean) => (safe ? filenamify(name) : name).slice(0, Math.max(0, maxLabelLength)); - const originalFileNames = await generate({ template: desiredTemplate, sanitizeName: (name: string) => sanitizeName(name, safeOutputFileName), safeOutputFileName }); + let originalFileNames: string[] | undefined; + let problems: GeneratedOutFileNames['problems']; + + try { + originalFileNames = await generate({ template: desiredTemplate, sanitizeName: (name: string) => sanitizeName(name, safeOutputFileName), safeOutputFileName }); + problems = getTemplateProblems({ fileNames: originalFileNames, filePath, outputDir, safeOutputFileName }); + } catch (err) { + console.warn(err); + problems = { + error: i18n.t('Template error: {{error}}', { error: err instanceof Error ? err.message : String(err) }), + }; + } - const problems = getTemplateProblems({ fileNames: originalFileNames, filePath, outputDir, safeOutputFileName }); if (problems.error != null) { const fileNames = await generate({ template: defaultTemplate, sanitizeName: (name: string) => sanitizeName(name, true), safeOutputFileName: true }); return { fileNames, originalFileNames, problems }; } + invariant(originalFileNames != null); return { fileNames: originalFileNames, problems }; } -export async function generateOutSegFileNames({ fileDuration, segmentsToExport: segmentsToExportIn, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding, exportCount, currentFileExportCount }: { +export async function generateCutFileNames({ fileDuration, segmentsToExport: segmentsToExportIn, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding, exportCount, currentFileExportCount }: { fileDuration: number | undefined, segmentsToExport: SegmentToExport[], template: string, @@ -271,7 +303,7 @@ export async function generateOutSegFileNames({ fileDuration, segmentsToExport: }, { concurrency: 5 }); }, desiredTemplate, - defaultTemplate: defaultOutSegTemplate, + defaultTemplate: defaultCutFileTemplate, filePath, outputDir, maxLabelLength, @@ -279,16 +311,9 @@ export async function generateOutSegFileNames({ fileDuration, segmentsToExport: }); } -export type GenerateOutFileNames = (template: string) => Promise<{ - fileNames: string[], - originalFileNames?: string[] | undefined, - problems: { - error: string | undefined; - sameAsInputFileNameWarning: boolean; - }, -}>; +export type GenerateMergedOutFileNames = (params: GenerateMergedOutFileNamesParams) => Promise; -export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, epochMs = Date.now(), exportCount, currentFileExportCount, segmentsToExport }: { +export async function generateCutMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, exportCount, currentFileExportCount, segLabels, epochMs = Date.now() }: { template: string, isCustomFormatSelected: boolean, fileFormat: string, @@ -296,10 +321,10 @@ export async function generateMergedFileNames({ template: desiredTemplate, isCus outputDir: string, safeOutputFileName: boolean, maxLabelLength: number, - epochMs?: number, exportCount: number, - currentFileExportCount?: number, - segmentsToExport?: SegmentToExport[], + currentFileExportCount: number, + segLabels: string[], + epochMs?: number, }) { return generateWithFallback({ generate: async ({ template, safeOutputFileName: safeOutputFileName2, sanitizeName }) => { @@ -311,7 +336,7 @@ export async function generateMergedFileNames({ template: desiredTemplate, isCus ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }), exportCount, currentFileExportCount, - segLabels: segmentsToExport ? segmentsToExport.map((seg) => sanitizeName(seg.name ?? '')) : [], + segLabels: segLabels.map((label) => sanitizeName(label)), }); return [maybeTruncatePath(fileName, safeOutputFileName2)]; @@ -324,3 +349,40 @@ export async function generateMergedFileNames({ template: desiredTemplate, isCus safeOutputFileName, }); } + +export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePaths, outputDir, safeOutputFileName, maxLabelLength, exportCount, epochMs }: { + template: string, + isCustomFormatSelected: boolean, + fileFormat: string, + filePaths: string[], + outputDir: string, + safeOutputFileName: boolean, + maxLabelLength: number, + exportCount: number, + epochMs: number, +}) { + const [firstPath] = filePaths; + invariant(firstPath != null); + + return generateWithFallback({ + generate: async ({ template, safeOutputFileName: safeOutputFileName2, sanitizeName }) => { + const { name: inputFileNameWithoutExt } = parsePath(firstPath); + + const fileName = await interpolateOutFileName(template, { + epochMs, + inputFileNameWithoutExt, + ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath: firstPath }), + exportCount, + segLabels: filePaths.map((filePath) => sanitizeName(basename(filePath))), + }); + + return [maybeTruncatePath(fileName, safeOutputFileName2)]; + }, + desiredTemplate, + defaultTemplate: defaultCutMergedFileTemplate, + filePath: firstPath, + outputDir, + maxLabelLength, + safeOutputFileName, + }); +} diff --git a/types.ts b/types.ts index 4c331d0c..55040726 100644 --- a/types.ts +++ b/types.ts @@ -82,8 +82,12 @@ export interface Config { autoLoadTimecode: boolean, segmentsToChapters: boolean, simpleMode: boolean, - outSegTemplate: string | undefined, + /** todo: rename to cutFileTemplate */ + outSegTemplate: string | undefined + /** todo: rename to cutMergedFileTemplate */ mergedFileTemplate: string | undefined, + /** todo: rename to mergedFileTemplate */ + mergedFilesTemplate: string | undefined, keyboardSeekAccFactor: number, keyboardNormalSeekSpeed: number, keyboardSeekSpeed2: number,