implement file name template also for merge files

also fallback even if template error

closes #2403
closes #2489
#2054 #2400
pull/2599/head
Mikael Finstad 9 months ago
parent 251102a935
commit 2511021783
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

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

@ -127,6 +127,7 @@ const defaults: Config = {
simpleMode: true,
outSegTemplate: undefined,
mergedFileTemplate: undefined,
mergedFilesTemplate: undefined,
keyboardSeekAccFactor: 1.03,
keyboardNormalSeekSpeed: 1,
keyboardSeekSpeed2: 10,

@ -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<string[]>([]);
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<string>();
const notices = new Set<string>();
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<ReturnType<typeof createChaptersFromSegments>>;
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<string>();
const warnings = new Set<string>();
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 */}
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} mergedFileTemplate={mergedFileTemplateOrDefault} setMergedFileTemplate={setMergedFileTemplate} generateOutSegFileNames={generateOutSegFileNames} generateMergedFileNames={generateMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} />
<ExportConfirm areWeCutting={areWeCutting} segmentsOrInverse={segmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmOpen} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} cutFileTemplate={cutFileTemplateOrDefault} cutMergedFileTemplate={cutMergedFileTemplateOrDefault} generateCutFileNames={generateCutFileNames} generateCutMergedFileNames={generateCutMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} isEncoding={isEncoding} encBitrate={encBitrate} setEncBitrate={setEncBitrate} toggleSettings={toggleSettings} outputPlaybackRate={outputPlaybackRate} lossyMode={lossyMode} />
<Dialog.Root open={streamsSelectorShown} onOpenChange={setStreamsSelectorShown}>
<Dialog.Portal>
@ -2742,7 +2757,7 @@ function App() {
</Dialog.Portal>
</Dialog.Root>
<ConcatSheet isShown={batchFiles.length > 0 && concatSheetOpen} onHide={() => setConcatSheetOpen(false)} paths={batchFilePaths} onConcat={userConcatFiles} setAlwaysConcatMultipleFiles={setAlwaysConcatMultipleFiles} alwaysConcatMultipleFiles={alwaysConcatMultipleFiles} exportCount={exportCount} maxLabelLength={maxLabelLength} />
<ConcatSheet isShown={batchFiles.length > 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} />
<KeyboardShortcuts isShown={keyboardShortcutsVisible} onHide={() => setKeyboardShortcutsVisible(false)} keyBindings={keyBindings} setKeyBindings={setKeyBindings} currentCutSeg={currentCutSeg} resetKeyBindings={resetKeyBindings} />

@ -138,14 +138,12 @@ function TopMenu({
{customOutDir ? t('Working dir set') : t('Working dir unset')}
</Button>
{filePath && (
<>
{renderOutFmt(outFmtStyle)}
{renderOutFmt(outFmtStyle)}
{!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()}
{!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()}
<ExportModeButton selectedSegments={selectedSegments} style={exportModeStyle} />
</>
{filePath && (
<ExportModeButton selectedSegments={selectedSegments} style={exportModeStyle} />
)}
{!simpleMode && (

@ -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<void>,
mergedFileTemplate: string,
generateMergedFileNames: GenerateMergedOutFileNames,
onConcat: (a: { paths: string[], includeAllStreams: boolean, streams: FFprobeStream[], fileFormat: string, clearBatchFilesAfterConcat: boolean, generatedFileNames: GeneratedOutFileNames }) => Promise<void>,
alwaysConcatMultipleFiles: boolean,
setAlwaysConcatMultipleFiles: (a: boolean) => void,
exportCount: number,
maxLabelLength: number,
ensureWritableOutDir: EnsureWritableOutDir,
fileFormat: string | undefined,
setFileFormat: Dispatch<SetStateAction<string | undefined>>,
detectedFileFormat: string | undefined,
setDetectedFileFormat: Dispatch<SetStateAction<string | undefined>>,
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<Record<string, {format: FFprobeFormat, streams: FFprobeStream[], chapters: FFprobeChapter[] }>>({});
const [clearBatchFilesAfterConcat, setClearBatchFilesAfterConcat] = useState(false);
const [settingsVisible, setSettingsVisible] = useState(false);
const [enableReadFileMeta, setEnableReadFileMeta] = useState(false);
const [outFileName, setOutFileName] = useState<string>();
const [uniqueSuffix, setUniqueSuffix] = useState<number>();
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 (
<ExportSheet
@ -191,7 +182,7 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil
title={t('Merge/concatenate files')}
onClosePress={onHide}
renderButton={() => (
<Button className={simpleMode ? 'export-animation' : undefined} disabled={detectedFileFormat == null || !isOutFileNameValid || outFileName == null} onClick={onConcatClick} style={{ fontSize: '1.3em', padding: '0 .3em', marginLeft: '1em', background: primaryColor, color: 'white', border: 'none' }}>
<Button className={simpleMode ? 'export-animation' : undefined} disabled={fileFormat == null} onClick={onConcatClick} style={{ fontSize: '1.3em', padding: '0 .3em', marginLeft: '1em', background: primaryColor, color: 'white', border: 'none' }}>
<AiOutlineMergeCells style={{ fontSize: '1.4em', verticalAlign: 'middle' }} /> {t('Merge!')}
</Button>
)}
@ -208,11 +199,13 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil
<div>
<span style={{ opacity: 0.7, marginRight: '.4em' }}>{`${index + 1}.`}</span>
<span>{basename(path)}</span>
{!allFilesMetaCache[path] && <FaQuestionCircle style={{ color: 'var(--orange-8)', verticalAlign: 'middle', marginLeft: '1em' }} />}
{problemsByFile[path] && (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em' }}><FaExclamationTriangle /></Button>
<Button title={i18n.t('Mismatches detected')} style={{ color: 'var(--orange-8)', marginLeft: '1em', padding: '.2em .4em' }}><FaExclamationTriangle /></Button>
</Dialog.Trigger>
<Dialog.Portal>
@ -238,58 +231,58 @@ function ConcatSheet({ isShown, onHide, paths, onConcat, alwaysConcatMultipleFil
<div style={{ marginBottom: '1em' }}>
<Checkbox style={{ marginBottom: '.7em' }} checked={enableReadFileMeta} onCheckedChange={(checked) => setEnableReadFileMeta(!!checked)} label={t('Check compatibility')} />
<Button onClick={() => setSettingsVisible(true)} style={{ padding: '.3em .5em', marginBottom: '.5em' }}><FaCog style={{ verticalAlign: 'top', fontSize: '1.4em', marginRight: '.2em' }} /> {t('Options')}</Button>
<Dialog.Root>
<Dialog.Trigger asChild>
<Button style={{ padding: '.3em .5em', marginBottom: '.5em' }}><FaCog style={{ verticalAlign: 'top', fontSize: '1.4em', marginRight: '.2em' }} /> {t('Options')}</Button>
</Dialog.Trigger>
<div>{t('Output container format:')}</div>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '40em' }} aria-describedby={undefined}>
<Dialog.Title>{t('Merge options')}</Dialog.Title>
{fileFormat && detectedFileFormat && (
<OutputFormatSelect style={{ height: '1.7em', maxWidth: '20em', marginBottom: '.7em' }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
)}
<Checkbox checked={includeAllStreams} onCheckedChange={(checked) => 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.')}`} />
<div style={{ marginBottom: '.3em' }}>{t('Output file name')}:</div>
<TextInput style={{ width: '100%', fontSize: '1.2em', padding: '.1em .3em', marginBottom: '.7em' }} value={outFileName ?? ''} onChange={(e) => setOutFileName(e.target.value)} />
<Checkbox checked={preserveMetadataOnMerge} onCheckedChange={(checked) => setPreserveMetadataOnMerge(checked === true)} label={t('Preserve original metadata when merging? (slow)')} />
{isOutFileNameTooLong && (
<Alert text={t('File name is too long and cannot be exported.')} />
)}
{enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && (
<Alert text={t('A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.')} />
)}
{!enableReadFileMeta && (
<Alert text={t('File compatibility check is not enabled, so the merge operation might not produce a valid output. Enable "Check compatibility" below to check file compatibility before merging.')} />
)}
</div>
{fileFormat != null && isMov(fileFormat) && <Checkbox checked={preserveMovData} onCheckedChange={(checked) => setPreserveMovData(checked === true)} label={t('Preserve all MP4/MOV metadata?')} />}
<Dialog.Root open={settingsVisible} onOpenChange={setSettingsVisible}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content style={{ width: '40em' }} aria-describedby={undefined}>
<Dialog.Title>{t('Merge options')}</Dialog.Title>
<Checkbox checked={segmentsToChapters} onCheckedChange={(checked) => setSegmentsToChapters(checked === true)} label={t('Create chapters from merged segments? (slow)')} />
<Checkbox checked={includeAllStreams} onCheckedChange={(checked) => 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.')}`} />
<Checkbox checked={alwaysConcatMultipleFiles} onCheckedChange={(checked) => setAlwaysConcatMultipleFiles(checked === true)} label={t('Always open this dialog when opening multiple files')} />
<Checkbox checked={preserveMetadataOnMerge} onCheckedChange={(checked) => setPreserveMetadataOnMerge(checked === true)} label={t('Preserve original metadata when merging? (slow)')} />
<Checkbox checked={clearBatchFilesAfterConcat} onCheckedChange={(checked) => setClearBatchFilesAfterConcat(checked === true)} label={t('Clear batch file list after merge')} />
{fileFormat != null && isMov(fileFormat) && <Checkbox checked={preserveMovData} onCheckedChange={(checked) => setPreserveMovData(checked === true)} label={t('Preserve all MP4/MOV metadata?')} />}
<p>{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.')}</p>
<Checkbox checked={segmentsToChapters} onCheckedChange={(checked) => setSegmentsToChapters(checked === true)} label={t('Create chapters from merged segments? (slow)')} />
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton primary>{t('Done')}</DialogButton>
</Dialog.Close>
</Dialog.ButtonRow>
<Checkbox checked={alwaysConcatMultipleFiles} onCheckedChange={(checked) => setAlwaysConcatMultipleFiles(checked === true)} label={t('Always open this dialog when opening multiple files')} />
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Checkbox checked={clearBatchFilesAfterConcat} onCheckedChange={(checked) => setClearBatchFilesAfterConcat(checked === true)} label={t('Clear batch file list after merge')} />
<div>{t('Output container format:')}</div>
<p>{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.')}</p>
{fileFormat != null && detectedFileFormat != null && (
<OutputFormatSelect style={{ height: '1.7em', maxWidth: '20em', marginBottom: '.7em' }} detectedFileFormat={detectedFileFormat} fileFormat={fileFormat} onOutputFormatUserChange={onOutputFormatUserChange} />
)}
<Dialog.ButtonRow>
<Dialog.Close asChild>
<DialogButton primary>{t('Done')}</DialogButton>
</Dialog.Close>
</Dialog.ButtonRow>
{fileFormat != null && (
<FileNameTemplateEditor mode="merge-files" template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateFileNames} />
)}
<Dialog.CloseButton />
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
{enableReadFileMeta && (!allFilesMeta || Object.values(problemsByFile).length > 0) && (
<Alert text={t('A mismatch was detected in at least one file. You may proceed, but the resulting file might not be playable.')} />
)}
{!enableReadFileMeta && (
<Alert text={t('File compatibility check is not enabled, so the merge operation might not produce a valid output. Enable "Check compatibility" below to check file compatibility before merging.')} />
)}
</div>
</ExportSheet>
);
}

@ -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 && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={outSegTemplate} setTemplate={setOutSegTemplate} defaultTemplate={defaultOutSegTemplate} generateFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
<FileNameTemplateEditor mode="separate" template={cutFileTemplate} setTemplate={setCutFileTemplate} defaultTemplate={defaultCutFileTemplate} generateFileNames={generateCutFileNames} currentSegIndexSafe={currentSegIndexSafe} />
</td>
<td>
<HelpIcon onClick={onOutSegTemplateHelpPress} />
<HelpIcon onClick={onCutFileTemplateHelpPress} />
</td>
</tr>
)}
@ -388,10 +384,10 @@ function ExportConfirm({
{willMerge && (
<tr>
<td colSpan={2}>
<FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
<FileNameTemplateEditor mode="merge-segments" template={cutMergedFileTemplate} setTemplate={setCutMergedFileTemplate} defaultTemplate={defaultCutMergedFileTemplate} generateFileNames={generateCutMergedFileNames} />
</td>
<td>
<HelpIcon onClick={onMergedFileTemplateHelpPress} />
<HelpIcon onClick={onCutMergedFileTemplateHelpPress} />
</td>
</tr>
)}

@ -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<string>();
const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
const [fileNames, setFileNames] = useState<string[]>();
const [generated, setGenerated] = useState<GeneratedOutFileNames>();
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 && (
<div>{(mergeMode ? t('Merged output file name:') : t('Output name(s):', { count: fileNames.length }))}</div>
{generated != null && (
<div>{
(mode === 'merge-files' || mode === 'merge-segments')
? t('Merged output file name:')
: t('Output name(s):', { count: generated.fileNames.length })
}
</div>
)}
<motion.div animate={{ marginBottom: needToShow ? '1.5em' : '.3em' }}>
{fileNames != null && (
{generated != null && (
<div style={{ marginBottom: '.3em' }}>
<HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>
{/* eslint-disable-next-line react/destructuring-assignment */}
{('currentSegIndexSafe' in opts ? fileNames[opts.currentSegIndexSafe] : undefined) || fileNames[0] || '-'}
<HighlightedText role="button" onClick={onShowClick} style={{ wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>
{generated.originalFileNames != null && formatCurrentSegFileOrFirst(generated.fileNames)}
<span style={generated.originalFileNames != null ? { textDecoration: 'line-through', marginLeft: '.3em', color: dangerColor } : undefined}>{formatCurrentSegFileOrFirst(generated.originalFileNames ?? generated.fileNames)}</span>
{!needToShow && <FaEdit style={{ fontSize: '.9em', marginLeft: '.4em', verticalAlign: 'middle' }} />}
</HighlightedText>
</div>
@ -168,7 +184,7 @@ function FileNameTemplateEditor(opts: {
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
<TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
{!mergeMode && fileNames != null && (
{generated != null && generated.fileNames.length > 1 && (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button style={{ marginLeft: '.3em' }}>{t('Preview')}</Button>
@ -177,10 +193,10 @@ function FileNameTemplateEditor(opts: {
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content aria-describedby={undefined}>
<Dialog.Title>{t('Resulting segment file names', { count: fileNames.length })}</Dialog.Title>
<Dialog.Title>{t('Resulting segment file names', { count: generated.fileNames.length })}</Dialog.Title>
<div style={{ overflowY: 'auto', maxHeight: 400 }}>
{fileNames.map((f) => <div key={f} style={{ marginBottom: '.5em' }}><FaFile style={{ verticalAlign: 'middle', marginRight: '.5em' }} />{f}</div>)}
{generated.fileNames.map((f) => <div key={f} style={{ marginBottom: '.5em' }}><FaFile style={{ verticalAlign: 'middle', marginRight: '.5em' }} />{f}</div>)}
</div>
<Dialog.CloseButton />
@ -223,26 +239,28 @@ function FileNameTemplateEditor(opts: {
)}
</AnimatePresence>
{problems.error != null ? (
{generated?.problems.error != null ? (
<div style={{ marginBottom: '1em', fontSize: '.9em' }}>
<FaExclamationTriangle color={dangerColor} style={{ verticalAlign: 'middle', fontSize: '1.1em' }} /> {problems.error}
<FaExclamationTriangle color={dangerColor} style={{ verticalAlign: 'middle', fontSize: '1.1em' }} /> {generated.problems.error}
</div>
) : (
<>
{problems.sameAsInputFileNameWarning && (
<div style={{ marginBottom: '1em' }}>
<FaExclamationTriangle style={{ verticalAlign: 'middle', marginRight: '.3em' }} color="var(--amber-9)" />
{i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')}
</div>
)}
generated != null && (
<>
{generated.problems.sameAsInputFileNameWarning && (
<div style={{ marginBottom: '1em' }}>
<FaExclamationTriangle style={{ verticalAlign: 'middle', marginRight: '.3em' }} color="var(--amber-9)" />
{i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')}
</div>
)}
{isMissingExtension && (
<div style={{ marginBottom: '1em' }}>
<FaExclamationTriangle style={{ verticalAlign: 'middle', marginRight: '.3em' }} color="var(--amber-9)" />
{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 })}
</div>
)}
</>
{isMissingExtension && (
<div style={{ marginBottom: '1em' }}>
<FaExclamationTriangle style={{ verticalAlign: 'middle', marginRight: '.3em' }} color="var(--amber-9)" />
{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 })}
</div>
)}
</>
)
)}
</motion.div>
</>

@ -174,10 +174,10 @@ export function useDialog() {
children: (
<UnorderedList>
<ListItem icon={<FaCheckCircle />} 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!')}</ListItem>
<Warnings warnings={warnings} />
<ListItem icon={<FaInfoCircle />}>{t('Please test the output file in your desired player/editor before you delete the source file.')}</ListItem>
<OutputIncorrectSeeHelpMenu />
<Notices notices={notices} />
<Warnings warnings={warnings} />
</UnorderedList>
),
});
@ -192,10 +192,10 @@ export function useDialog() {
children: (
<UnorderedList>
<ListItem icon={<FaCheckCircle />} iconColor={hasWarnings ? 'warning' : 'success'} style={{ fontWeight: 'bold' }}>{hasWarnings ? t('Files merged with warning(s)', { count: warnings.length }) : t('Files merged!')}</ListItem>
<Warnings warnings={warnings} />
<ListItem icon={<FaInfoCircle />}>{t('Please test the output files in your desired player/editor before you delete the source files.')}</ListItem>
<OutputIncorrectSeeHelpMenu />
<Notices notices={notices} />
<Warnings warnings={warnings} />
</UnorderedList>
),
});

@ -468,6 +468,7 @@ const DifferentFileSuggestion = () => <li><Trans>Try with a <b>Different file</b
const HelpSuggestion = () => <li><Trans>See <b>Help</b></Trans> menu</li>;
const ErrorReportSuggestion = () => <li><Trans>If nothing helps, you can send an <b>Error report</b></Trans></li>;
// todo Dialog component
export async function showExportFailedDialog({ fileFormat, safeOutputFileName }: { fileFormat: string | undefined, safeOutputFileName: boolean }) {
const html = (
<div style={{ textAlign: 'left' }}>
@ -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 = (
<div style={{ textAlign: 'left' }}>

@ -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<typeof useDirectoryAccess>['ensureWritableOutDir'];

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

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

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

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

Loading…
Cancel
Save