1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 02:12:30 +01:00

use template editor for merge too

#2108
and fix some types
This commit is contained in:
Mikael Finstad 2024-08-23 23:23:03 +02:00
parent b7b47c8516
commit 624efa7dbc
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26
12 changed files with 254 additions and 149 deletions

View File

@ -4,20 +4,20 @@
When exporting multiple segments as separate files, LosslessCut offers you the ability to specify how the output files will be named in sequence using a *template string*. The template string is evaluated as a [JavaScript template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), so you can use JavaScript syntax inside of it. The following variables are available in the template to customize the filenames: When exporting multiple segments as separate files, LosslessCut offers you the ability to specify how the output files will be named in sequence using a *template string*. The template string is evaluated as a [JavaScript template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), so you can use JavaScript syntax inside of it. The following variables are available in the template to customize the filenames:
| Variable | Output | | Avail when merging? | Variable | Output |
| -------------- | - | | - | - | - |
| `${FILENAME}` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`). | ✅ | `${FILENAME}` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`).
| `${EXT}` | The extension of the file (e.g.: `.mp4`, `.mkv`). | ✅ | `${EXT}` | The extension of the file (e.g.: `.mp4`, `.mkv`).
| `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`). | ✅ | `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite.
| `${SEG_NUM_INT}` | Number of the segment, as a raw integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`. | | `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`).
| `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`). | | `${SEG_NUM_INT}` | Number of the segment, as a raw integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`.
| `${SEG_LABEL}` | The label of the segment (e.g. `Getting_Lunch`). | | `${SEG_LABEL}` | The label of the segment (e.g. `Getting_Lunch`).
| `${SEG_SUFFIX}` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment number prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`). | | `${SEG_SUFFIX}` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment number prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`).
| `${CUT_FROM}` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`). | | `${CUT_FROM}` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`).
| `${CUT_TO}` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`). | | `${CUT_TO}` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`).
| `${SEG_TAGS.XX}` | 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 return the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}` | | `${SEG_TAGS.XX}` | 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 return 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 they 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}` 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}`
## Export project formats ## Export project formats

View File

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

View File

@ -66,7 +66,7 @@ import {
getOutPath, getSuffixedOutPath, handleError, getOutDir, getOutPath, getSuffixedOutPath, handleError, getOutDir,
isStoreBuild, dragPreventer, isStoreBuild, dragPreventer,
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType, deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString, calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
isMuxNotSupported, isMuxNotSupported,
getDownloadMediaOutPath, getDownloadMediaOutPath,
@ -79,7 +79,7 @@ import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenActio
import { openSendReportDialog } from './reporting'; import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n'; import { fallbackLng } from './i18n';
import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments'; import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
import { generateOutSegFileNames as generateOutSegFileNamesRaw, defaultOutSegTemplate } from './util/outputNameTemplate'; import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultMergedFileTemplate } from './util/outputNameTemplate';
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
import BigWaveform from './components/BigWaveform'; import BigWaveform from './components/BigWaveform';
@ -136,7 +136,6 @@ function App() {
const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false); const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false);
const [exportConfirmVisible, setExportConfirmVisible] = useState(false); const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
const [cacheBuster, setCacheBuster] = useState(0); const [cacheBuster, setCacheBuster] = useState(0);
const [mergedOutFileName, setMergedOutFileName] = useState<string>();
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
@ -167,7 +166,7 @@ function App() {
const allUserSettings = useUserSettingsRoot(); const allUserSettings = useUserSettingsRoot();
const { const {
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames, captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, mergedFileTemplate, setMergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
} = allUserSettings; } = allUserSettings;
const { working, setWorking, workingRef, abortWorking } = useLoading(); const { working, setWorking, workingRef, abortWorking } = useLoading();
@ -188,6 +187,7 @@ function App() {
}, [customFfPath]); }, [customFfPath]);
const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate; const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate;
const mergedFileTemplateOrDefault = mergedFileTemplate || defaultMergedFileTemplate;
useEffect(() => { useEffect(() => {
const l = language || fallbackLng; const l = language || fallbackLng;
@ -537,15 +537,6 @@ function App() {
const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow }); const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow });
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, duration }); const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, duration });
const resetMergedOutFileName = useCallback(() => {
if (fileFormat == null || filePath == null) return;
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
const outFileName = getSuffixedFileName(filePath, `cut-merged-${Date.now()}${ext}`);
setMergedOutFileName(outFileName);
}, [fileFormat, filePath, isCustomFormatSelected]);
useEffect(() => resetMergedOutFileName(), [resetMergedOutFileName]);
const resetState = useCallback(() => { const resetState = useCallback(() => {
console.log('State reset'); console.log('State reset');
const video = videoRef.current; const video = videoRef.current;
@ -587,11 +578,10 @@ function App() {
setActiveSubtitleStreamIndex(undefined); setActiveSubtitleStreamIndex(undefined);
setHideMediaSourcePlayer(false); setHideMediaSourcePlayer(false);
setExportConfirmVisible(false); setExportConfirmVisible(false);
resetMergedOutFileName();
setOutputPlaybackRateState(1); setOutputPlaybackRateState(1);
cancelRenderThumbnails(); cancelRenderThumbnails();
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, resetMergedOutFileName, setOutputPlaybackRateState, cancelRenderThumbnails]); }, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState, cancelRenderThumbnails]);
const showUnsupportedFileMessage = useCallback(() => { const showUnsupportedFileMessage = useCallback(() => {
@ -805,10 +795,11 @@ function App() {
shortestFlag, shortestFlag,
effectiveExportMode, effectiveExportMode,
outSegTemplate, outSegTemplate,
mergedFileTemplate,
}; };
openSendReportDialog(err, state); openSendReportDialog(err, state);
}, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, outSegTemplate, rotation, shortestFlag]); }, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, mergedFileTemplate, outSegTemplate, rotation, shortestFlag]);
const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => { const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => {
const state = { ...commonSettings, ...reportState }; const state = { ...commonSettings, ...reportState };
@ -961,18 +952,19 @@ function App() {
}, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]); }, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]);
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => { const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
if (fileFormat == null || outputDir == null || filePath == null) throw new Error(); invariant(fileFormat != null && outputDir != null && filePath != null);
return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }); return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
}, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]); }, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
const generateMergedFileNames = useCallback(async ({ template }: { template: string }) => {
invariant(fileFormat != null && filePath != null);
return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName });
}, [fileFormat, filePath, isCustomFormatSelected, outputDir, safeOutputFileName]);
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []); const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
const willMerge = segmentsToExport.length > 1 && autoMerge; const willMerge = segmentsToExport.length > 1 && autoMerge;
const mergedOutFilePath = useMemo(() => (
mergedOutFileName != null ? getOutPath({ customOutDir, filePath, fileName: mergedOutFileName }) : undefined
), [customOutDir, filePath, mergedOutFileName]);
const onExportConfirm = useCallback(async () => { const onExportConfirm = useCallback(async () => {
invariant(filePath != null); invariant(filePath != null);
@ -1010,7 +1002,7 @@ function App() {
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault); console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
const { outSegFileNames, outSegProblems } = await generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault }); const { fileNames: outSegFileNames, problems: outSegProblems } = await generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
if (outSegProblems.error != null) { if (outSegProblems.error != null) {
console.warn('Output segments file name invalid, using default instead', outSegFileNames); console.warn('Output segments file name invalid, using default instead', outSegFileNames);
} }
@ -1040,12 +1032,25 @@ function App() {
detectedFps, detectedFps,
}); });
let mergedOutFilePath: string | undefined;
if (willMerge) { if (willMerge) {
console.log('mergedFileTemplateOrDefault', mergedFileTemplateOrDefault);
setCutProgress(0); setCutProgress(0);
setWorking({ text: i18n.t('Merging') }); setWorking({ text: i18n.t('Merging') });
const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined; const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined;
const { fileNames, problems } = await generateMergedFileNames({ template: mergedFileTemplateOrDefault });
if (problems.error != null) {
console.warn('Merged file name invalid, using default instead', fileNames[0]);
}
const [fileName] = fileNames;
invariant(fileName != null);
mergedOutFilePath = getOutPath({ customOutDir, filePath, fileName });
await autoConcatCutSegments({ await autoConcatCutSegments({
customOutDir, customOutDir,
outFormat: fileFormat, outFormat: fileFormat,
@ -1089,7 +1094,7 @@ function App() {
if (areWeCutting) notices.push(i18n.t('Cutpoints may be inaccurate.')); if (areWeCutting) notices.push(i18n.t('Cutpoints may be inaccurate.'));
const revealPath = willMerge ? mergedOutFilePath : outFiles[0]; const revealPath = willMerge && mergedOutFilePath != null ? mergedOutFilePath : outFiles[0];
invariant(revealPath != null); invariant(revealPath != null);
if (!hideAllNotifications) { if (!hideAllNotifications) {
showOsNotification(i18n.t('Export finished')); showOsNotification(i18n.t('Export finished'));
@ -1097,8 +1102,6 @@ function App() {
} }
if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog(); if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog();
resetMergedOutFileName();
} catch (err) { } catch (err) {
if (isExecaError(err)) { if (isExecaError(err)) {
if (err.killed) { if (err.killed) {
@ -1129,7 +1132,7 @@ function App() {
setWorking(undefined); setWorking(undefined);
setCutProgress(undefined); setCutProgress(undefined);
} }
}, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, resetMergedOutFileName, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]); }, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]);
const onExportPress = useCallback(async () => { const onExportPress = useCallback(async () => {
if (!filePath) return; if (!filePath) return;
@ -2586,7 +2589,7 @@ function App() {
/> />
</div> </div>
<ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} smartCutBitrate={smartCutBitrate} setSmartCutBitrate={setSmartCutBitrate} toggleSettings={toggleSettings} /> <ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} 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} smartCutBitrate={smartCutBitrate} setSmartCutBitrate={setSmartCutBitrate} toggleSettings={toggleSettings} />
<Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}> <Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}>
{mainStreams && filePath != null && ( {mainStreams && filePath != null && (

View File

@ -12,11 +12,10 @@ import ExportModeButton from './ExportModeButton';
import PreserveMovDataButton from './PreserveMovDataButton'; import PreserveMovDataButton from './PreserveMovDataButton';
import MovFastStartButton from './MovFastStartButton'; import MovFastStartButton from './MovFastStartButton';
import ToggleExportConfirm from './ToggleExportConfirm'; import ToggleExportConfirm from './ToggleExportConfirm';
import OutSegTemplateEditor from './OutSegTemplateEditor'; import FileNameTemplateEditor from './FileNameTemplateEditor';
import HighlightedText, { highlightedTextStyle } from './HighlightedText'; import HighlightedText, { highlightedTextStyle } from './HighlightedText';
import Select from './Select'; import Select from './Select';
import Switch from './Switch'; import Switch from './Switch';
import MergedOutFileName from './MergedOutFileName';
import { primaryTextColor } from '../colors'; import { primaryTextColor } from '../colors';
import { withBlur } from '../util'; import { withBlur } from '../util';
@ -25,7 +24,7 @@ import { isMov as ffmpegIsMov } from '../util/streams';
import useUserSettings from '../hooks/useUserSettings'; import useUserSettings from '../hooks/useUserSettings';
import styles from './ExportConfirm.module.css'; import styles from './ExportConfirm.module.css';
import { InverseCutSegment, SegmentToExport } from '../types'; import { InverseCutSegment, SegmentToExport } from '../types';
import { GenerateOutSegFileNames } from '../util/outputNameTemplate'; import { defaultMergedFileTemplate, defaultOutSegTemplate, GenerateOutFileNames } from '../util/outputNameTemplate';
import { FFprobeStream } from '../../../../ffprobe'; import { FFprobeStream } from '../../../../ffprobe';
import { AvoidNegativeTs } from '../../../../types'; import { AvoidNegativeTs } from '../../../../types';
import TextInput from './TextInput'; import TextInput from './TextInput';
@ -55,13 +54,14 @@ function ExportConfirm({
onShowStreamsSelectorClick, onShowStreamsSelectorClick,
outSegTemplate, outSegTemplate,
setOutSegTemplate, setOutSegTemplate,
mergedFileTemplate,
setMergedFileTemplate,
generateOutSegFileNames, generateOutSegFileNames,
generateMergedFileNames,
currentSegIndexSafe, currentSegIndexSafe,
nonFilteredSegmentsOrInverse, nonFilteredSegmentsOrInverse,
mainCopiedThumbnailStreams, mainCopiedThumbnailStreams,
needSmartCut, needSmartCut,
mergedOutFileName,
setMergedOutFileName,
smartCutBitrate, smartCutBitrate,
setSmartCutBitrate, setSmartCutBitrate,
toggleSettings, toggleSettings,
@ -81,13 +81,14 @@ function ExportConfirm({
onShowStreamsSelectorClick: () => void, onShowStreamsSelectorClick: () => void,
outSegTemplate: string, outSegTemplate: string,
setOutSegTemplate: (a: string) => void, setOutSegTemplate: (a: string) => void,
generateOutSegFileNames: GenerateOutSegFileNames, mergedFileTemplate: string,
setMergedFileTemplate: (a: string) => void,
generateOutSegFileNames: GenerateOutFileNames,
generateMergedFileNames: GenerateOutFileNames,
currentSegIndexSafe: number, currentSegIndexSafe: number,
nonFilteredSegmentsOrInverse: InverseCutSegment[], nonFilteredSegmentsOrInverse: InverseCutSegment[],
mainCopiedThumbnailStreams: FFprobeStream[], mainCopiedThumbnailStreams: FFprobeStream[],
needSmartCut: boolean, needSmartCut: boolean,
mergedOutFileName: string | undefined,
setMergedOutFileName: (a: string) => void,
smartCutBitrate: number | undefined, smartCutBitrate: number | undefined,
setSmartCutBitrate: Dispatch<SetStateAction<number | undefined>>, setSmartCutBitrate: Dispatch<SetStateAction<number | undefined>>,
toggleSettings: () => void, toggleSettings: () => void,
@ -147,6 +148,10 @@ function ExportConfirm({
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the output segment(s) using special variables.', { count: segmentsToExport.length }) }); toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the output segment(s) using special variables.', { count: segmentsToExport.length }) });
}, [segmentsToExport.length]); }, [segmentsToExport.length]);
const onMergedFileTemplateHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the merged file using special variables.') });
}, []);
const onExportModeHelpPress = useCallback(() => { const onExportModeHelpPress = useCallback(() => {
toast.fire({ icon: 'info', timer: 10000, text: exportModeDescription }); toast.fire({ icon: 'info', timer: 10000, text: exportModeDescription });
}, [exportModeDescription]); }, [exportModeDescription]);
@ -171,7 +176,7 @@ function ExportConfirm({
toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') }); toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') });
}, [t]); }, [t]);
const canEditTemplate = !willMerge || !autoDeleteMergedSegments; const canEditSegTemplate = !willMerge || !autoDeleteMergedSegments;
const handleSmartCutBitrateToggle = useCallback((checked: boolean) => { const handleSmartCutBitrateToggle = useCallback((checked: boolean) => {
setSmartCutBitrate(() => (checked ? undefined : 10000)); setSmartCutBitrate(() => (checked ? undefined : 10000));
@ -269,10 +274,10 @@ function ExportConfirm({
<td /> <td />
</tr> </tr>
{canEditTemplate && ( {canEditSegTemplate && (
<tr> <tr>
<td colSpan={2}> <td colSpan={2}>
<OutSegTemplateEditor outSegTemplate={outSegTemplate} setOutSegTemplate={setOutSegTemplate} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} /> <FileNameTemplateEditor template={outSegTemplate} setTemplate={setOutSegTemplate} defaultTemplate={defaultOutSegTemplate} generateFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
</td> </td>
<td> <td>
<HelpIcon onClick={onOutSegTemplateHelpPress} /> <HelpIcon onClick={onOutSegTemplateHelpPress} />
@ -282,14 +287,11 @@ function ExportConfirm({
{willMerge && ( {willMerge && (
<tr> <tr>
<td> <td colSpan={2}>
{t('Merged output file name:')} <FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
</td> </td>
<td> <td>
<MergedOutFileName mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} /> <HelpIcon onClick={onMergedFileTemplateHelpPress} />
</td>
<td>
<HelpIcon onClick={() => showHelpText({ text: t('Name of the merged/concatenated output file when concatenating multiple segments.') })} />
</td> </td>
</tr> </tr>
)} )}

View File

@ -1,4 +1,4 @@
import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { memo, useState, useEffect, useCallback, useRef, useMemo, ChangeEventHandler } from 'react';
import { useDebounce } from 'use-debounce'; import { useDebounce } from 'use-debounce';
import i18n from 'i18next'; import i18n from 'i18next';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@ -9,7 +9,7 @@ import { FaEdit } from 'react-icons/fa';
import { ReactSwal } from '../swal'; import { ReactSwal } from '../swal';
import HighlightedText from './HighlightedText'; import HighlightedText from './HighlightedText';
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate'; import { segNumVariable, segSuffixVariable, GenerateOutFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate';
import useUserSettings from '../hooks/useUserSettings'; import useUserSettings from '../hooks/useUserSettings';
import Switch from './Switch'; import Switch from './Switch';
import Select from './Select'; import Select from './Select';
@ -17,21 +17,32 @@ import TextInput from './TextInput';
const electron = window.require('electron'); const electron = window.require('electron');
const formatVariable = (variable) => `\${${variable}}`;
const formatVariable = (variable: string) => `\${${variable}}`;
const extVariableFormatted = formatVariable(extVariable); const extVariableFormatted = formatVariable(extVariable);
const segTagsExample = `${segTagsVariable}.XX`; const segTagsExample = `${segTagsVariable}.XX`;
function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: { function FileNameTemplateEditor(opts: {
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number, template: string,
}) { setTemplate: (text: string) => void,
defaultTemplate: string,
generateFileNames: GenerateOutFileNames,
} & ({
currentSegIndexSafe: number,
mergeMode?: false
} | {
mergeMode: true
})) {
const { template: templateIn, setTemplate, defaultTemplate, generateFileNames, mergeMode } = opts;
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings(); const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
const [text, setText] = useState(outSegTemplate); const [text, setText] = useState(templateIn);
const [debouncedText] = useDebounce(text, 500); const [debouncedText] = useDebounce(text, 500);
const [validText, setValidText] = useState<string>(); const [validText, setValidText] = useState<string>();
const [outSegProblems, setOutSegProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false }); const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
const [outSegFileNames, setOutSegFileNames] = useState<string[]>(); const [fileNames, setFileNames] = useState<string[]>();
const [shown, setShown] = useState<boolean>(); const [shown, setShown] = useState<boolean>();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -48,59 +59,64 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
(async () => { (async () => {
try { try {
// console.time('generateOutSegFileNames') // console.time('generateFileNames')
const outSegs = await generateOutSegFileNames({ template: debouncedText }); const outSegs = await generateFileNames({ template: debouncedText });
// console.timeEnd('generateOutSegFileNames') // console.timeEnd('generateOutSegFileNames')
if (abortController.signal.aborted) return; if (abortController.signal.aborted) return;
setOutSegFileNames(outSegs.outSegFileNames); setFileNames(outSegs.fileNames);
setOutSegProblems(outSegs.outSegProblems); setProblems(outSegs.problems);
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined); setValidText(outSegs.problems.error == null ? debouncedText : undefined);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setValidText(undefined); setValidText(undefined);
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) }); setProblems({ error: err instanceof Error ? err.message : String(err) });
} }
})(); })();
return () => abortController.abort(); return () => abortController.abort();
}, [debouncedText, generateOutSegFileNames, t]); }, [debouncedText, generateFileNames, t]);
const availableVariables = useMemo(() => (mergeMode
? ['FILENAME', extVariable, 'EPOCH_MS']
: ['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS']
), [mergeMode]);
// eslint-disable-next-line no-template-curly-in-string // eslint-disable-next-line no-template-curly-in-string
const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted); const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted);
const onAllSegmentsPreviewPress = useCallback(() => { const onAllFilesPreviewPress = useCallback(() => {
if (outSegFileNames == null) return; if (fileNames == null) return;
ReactSwal.fire({ ReactSwal.fire({
title: t('Resulting segment file names', { count: outSegFileNames.length }), title: t('Resulting segment file names', { count: fileNames.length }),
html: ( html: (
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}> <div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)} {fileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
</div> </div>
), ),
}); });
}, [outSegFileNames, t]); }, [fileNames, t]);
useEffect(() => { useEffect(() => {
if (validText != null) setOutSegTemplate(validText); if (validText != null) setTemplate(validText);
}, [validText, setOutSegTemplate]); }, [validText, setTemplate]);
const reset = useCallback(() => { const reset = useCallback(() => {
setOutSegTemplate(defaultOutSegTemplate); setTemplate(defaultTemplate);
setText(defaultOutSegTemplate); setText(defaultTemplate);
}, [setOutSegTemplate]); }, [defaultTemplate, setTemplate]);
const onHideClick = useCallback(() => { const onHideClick = useCallback(() => {
if (outSegProblems.error == null) setShown(false); if (problems.error == null) setShown(false);
}, [outSegProblems.error]); }, [problems.error]);
const onShowClick = useCallback(() => { const onShowClick = useCallback(() => {
if (!shown) setShown(true); if (!shown) setShown(true);
}, [shown]); }, [shown]);
const onTextChange = useCallback((e) => setText(e.target.value), []); const onTextChange = useCallback<ChangeEventHandler<HTMLInputElement>>((e) => setText(e.target.value), []);
const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning; const haveImportantMessage = problems.error != null || problems.sameAsInputFileNameWarning;
const needToShow = shown || gotImportantMessage; const needToShow = shown || haveImportantMessage;
const onVariableClick = useCallback((variable: string) => { const onVariableClick = useCallback((variable: string) => {
const input = inputRef.current; const input = inputRef.current;
@ -115,12 +131,13 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
}, [text]); }, [text]);
return ( return (
<motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : 0 }}> <motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : '0 0 .3em 0' }}>
<div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div> <div>{fileNames != null && (mergeMode ? t('Merged output file name:') : t('Output name(s):', { count: fileNames.length }))}</div>
{outSegFileNames != null && ( {fileNames != null && (
<HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}> <HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>
{outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'} {/* eslint-disable-next-line react/destructuring-assignment */}
{('currentSegIndexSafe' in opts ? fileNames[opts.currentSegIndexSafe] : undefined) || fileNames[0] || '-'}
{!needToShow && <FaEdit style={{ fontSize: '.9em', marginLeft: '.4em', verticalAlign: 'middle' }} />} {!needToShow && <FaEdit style={{ fontSize: '.9em', marginLeft: '.4em', verticalAlign: 'middle' }} />}
</HighlightedText> </HighlightedText>
)} )}
@ -137,28 +154,28 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}> <div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
<TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" /> <TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>} {!mergeMode && fileNames != null && <Button height={20} onClick={onAllFilesPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
<IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" /> <IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" />
{!gotImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />} {!haveImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />}
</div> </div>
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}> <div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
{`${i18n.t('Variables')}:`} {`${i18n.t('Variables')}:`}
<IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} /> <IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} />
{['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS'].map((variable) => ( {availableVariables.map((variable) => (
<span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span> <span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span>
))} ))}
</div> </div>
{outSegProblems.error != null && ( {problems.error != null && (
<div style={{ marginBottom: '1em' }}> <div style={{ marginBottom: '1em' }}>
<ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {outSegProblems.error} <ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {problems.error}
</div> </div>
)} )}
{outSegProblems.error == null && outSegProblems.sameAsInputFileNameWarning && ( {problems.error == null && problems.sameAsInputFileNameWarning && (
<div style={{ marginBottom: '1em' }}> <div style={{ marginBottom: '1em' }}>
<WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '} <WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '}
{i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')} {i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')}
@ -177,7 +194,7 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}> <Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}>
{Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)} {Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
</Select> </Select>
Minimum numeric padded length {t('Minimum numeric padded length')}
</div> </div>
)} )}
@ -194,4 +211,4 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
); );
} }
export default memo(OutSegTemplateEditor); export default memo(FileNameTemplateEditor);

View File

@ -1,14 +0,0 @@
import { memo } from 'react';
import TextInput from './TextInput';
function MergedOutFileName({ mergedOutFileName, setMergedOutFileName }: { mergedOutFileName: string | undefined, setMergedOutFileName: (a: string) => void }) {
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<TextInput value={mergedOutFileName ?? ''} onChange={(e) => setMergedOutFileName(e.target.value)} style={{ textAlign: 'right' }} />
</div>
);
}
export default memo(MergedOutFileName);

View File

@ -219,7 +219,7 @@ export async function tryMapChaptersToEdl(chapters: FFprobeChapter[]) {
} }
} }
export async function createChaptersFromSegments({ segmentPaths, chapterNames }: { segmentPaths: string[], chapterNames?: string[] }) { export async function createChaptersFromSegments({ segmentPaths, chapterNames }: { segmentPaths: string[], chapterNames?: (string | undefined)[] | undefined }) {
if (!chapterNames) return undefined; if (!chapterNames) return undefined;
try { try {
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 }); const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });

View File

@ -82,7 +82,20 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', String(1 / outputPlaybackRate)] : []), [outputPlaybackRate]); const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', String(1 / outputPlaybackRate)] : []), [outputPlaybackRate]);
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => undefined, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase }: { const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => undefined, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase }: {
paths: string[], outDir: string | undefined, outPath: string, metadataFromPath: string, includeAllStreams: boolean, streams: FFprobeStream[], outFormat: string, ffmpegExperimental: boolean, onProgress?: (a: number) => void, preserveMovData: boolean, movFastStart: boolean, chapters: Chapter[] | undefined, preserveMetadataOnMerge: boolean, videoTimebase?: number | undefined, paths: string[],
outDir: string | undefined,
outPath: string,
metadataFromPath: string,
includeAllStreams: boolean,
streams: FFprobeStream[],
outFormat?: string | undefined,
ffmpegExperimental: boolean,
onProgress?: (a: number) => void,
preserveMovData: boolean,
movFastStart: boolean,
chapters: Chapter[] | undefined,
preserveMetadataOnMerge: boolean,
videoTimebase?: number | undefined,
}) => { }) => {
if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false }; if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false };
@ -500,7 +513,19 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
} }
}, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, smartCutCustomBitrate, appendFfmpegCommandLog, concatFiles]); }, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, smartCutCustomBitrate, appendFfmpegCommandLog, concatFiles]);
const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }) => { const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }: {
customOutDir: string | undefined,
outFormat: string | undefined,
segmentPaths: string[],
ffmpegExperimental: boolean,
onProgress: (p: number) => void,
preserveMovData: boolean,
movFastStart: boolean,
autoDeleteMergedSegments: boolean,
chapterNames: (string | undefined)[] | undefined,
preserveMetadataOnMerge: boolean,
mergedOutFilePath: string,
}) => {
const outDir = getOutDir(customOutDir, filePath); const outDir = getOutDir(customOutDir, filePath);
if (await shouldSkipExistingFile(mergedOutFilePath)) return; if (await shouldSkipExistingFile(mergedOutFilePath)) return;
@ -508,6 +533,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames }); const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
const metadataFromPath = segmentPaths[0]; const metadataFromPath = segmentPaths[0];
invariant(metadataFromPath != null);
// need to re-read streams because may have changed // need to re-read streams because may have changed
const { streams } = await readFileMeta(metadataFromPath); const { streams } = await readFileMeta(metadataFromPath);
await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }); await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });

View File

@ -97,6 +97,8 @@ export default () => {
useEffect(() => safeSetConfig({ simpleMode }), [simpleMode]); useEffect(() => safeSetConfig({ simpleMode }), [simpleMode]);
const [outSegTemplate, setOutSegTemplate] = useState(safeGetConfigInitial('outSegTemplate')); const [outSegTemplate, setOutSegTemplate] = useState(safeGetConfigInitial('outSegTemplate'));
useEffect(() => safeSetConfig({ outSegTemplate }), [outSegTemplate]); useEffect(() => safeSetConfig({ outSegTemplate }), [outSegTemplate]);
const [mergedFileTemplate, setMergedFileTemplate] = useState(safeGetConfigInitial('mergedFileTemplate'));
useEffect(() => safeSetConfig({ mergedFileTemplate }), [mergedFileTemplate]);
const [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor')); const [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor'));
useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]); useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]);
const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed')); const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed'));
@ -224,6 +226,8 @@ export default () => {
setSimpleMode, setSimpleMode,
outSegTemplate, outSegTemplate,
setOutSegTemplate, setOutSegTemplate,
mergedFileTemplate,
setMergedFileTemplate,
keyboardSeekAccFactor, keyboardSeekAccFactor,
setKeyboardSeekAccFactor, setKeyboardSeekAccFactor,
keyboardNormalSeekSpeed, keyboardNormalSeekSpeed,

View File

@ -18,8 +18,11 @@ export const segTagsVariable = 'SEG_TAGS';
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path'); const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path');
function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName }: { function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName }: {
fileNames: string[], filePath: string, outputDir: string, safeOutputFileName: boolean fileNames: string[],
filePath: string,
outputDir: string,
safeOutputFileName: boolean,
}) { }) {
let error: string | undefined; let error: string | undefined;
let sameAsInputFileNameWarning = false; let sameAsInputFileNameWarning = false;
@ -30,7 +33,7 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
break; break;
} }
const invalidChars = new Set(); const invalidChars = new Set<string>();
// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names // https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
// note that we allow path separators in some cases (see below) // note that we allow path separators in some cases (see below)
@ -87,7 +90,7 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
} }
} }
if (error == null && hasDuplicates(fileNames)) { if (error == null && fileNames.length > 1 && hasDuplicates(fileNames)) {
error = i18n.t('Output file name template results in duplicate file names (you are trying to export multiple files with the same name). You can fix this for example by adding the "{{segNumVariable}}" variable.', { segNumVariable }); error = i18n.t('Output file name template results in duplicate file names (you are trying to export multiple files with the same name). You can fix this for example by adding the "{{segNumVariable}}" variable.', { segNumVariable });
} }
@ -100,10 +103,22 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
// This is used as a fallback and so it has to always generate unique file names // 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 // eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}'; export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
// eslint-disable-next-line no-template-curly-in-string
export const defaultMergedFileTemplate = '${FILENAME}-cut-merged-${EPOCH_MS}${EXT}';
async function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt, segSuffix, ext, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: { async function interpolateOutFileName(template: string, { epochMs, inputFileNameWithoutExt, ext, segSuffix, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: {
template: string, epochMs: number, inputFileNameWithoutExt: string, segSuffix: string, ext: string, segNum: number, segNumPadded: string, segLabel: string, cutFrom: string, cutTo: string, tags: Record<string, string> epochMs: number,
}) { inputFileNameWithoutExt: string,
ext: string,
} & Partial<{
segSuffix: string,
segNum: number,
segNumPadded: string,
segLabel: string,
cutFrom: string,
cutTo: string,
tags: Record<string, string>,
}>) {
const context = { const context = {
FILENAME: inputFileNameWithoutExt, FILENAME: inputFileNameWithoutExt,
[segSuffixVariable]: segSuffix, [segSuffixVariable]: segSuffix,
@ -114,7 +129,7 @@ async function interpolateSegmentFileName({ template, epochMs, inputFileNameWith
EPOCH_MS: epochMs, EPOCH_MS: epochMs,
CUT_FROM: cutFrom, CUT_FROM: cutFrom,
CUT_TO: cutTo, CUT_TO: cutTo,
[segTagsVariable]: { [segTagsVariable]: tags && {
// allow both original case and uppercase // allow both original case and uppercase
...tags, ...tags,
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])), ...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
@ -126,10 +141,33 @@ async function interpolateSegmentFileName({ template, epochMs, inputFileNameWith
return ret; return ret;
} }
function maybeTruncatePath(fileName: string, truncate: boolean) {
// Split the path by its separator, so we can check the actual file name (last path seg)
const pathSegs = fileName.split(pathSep);
if (pathSegs.length === 0) return '';
const [lastSeg] = pathSegs.slice(-1);
const rest = pathSegs.slice(0, -1);
return [
...rest,
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
truncate ? lastSeg!.slice(0, 200) : lastSeg,
].join(pathSep);
}
export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: { export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: {
segments: SegmentToExport[], template: string, formatTimecode: FormatTimecode, isCustomFormatSelected: boolean, fileFormat: string, filePath: string, outputDir: string, safeOutputFileName: boolean, maxLabelLength: number, outputFileNameMinZeroPadding: number, segments: SegmentToExport[],
template: string,
formatTimecode: FormatTimecode,
isCustomFormatSelected: boolean,
fileFormat: string,
filePath: string,
outputDir: string,
safeOutputFileName: boolean,
maxLabelLength: number,
outputFileNameMinZeroPadding: number,
}) { }) {
function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) { async function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) {
const epochMs = Date.now(); const epochMs = Date.now();
return pMap(segments, async (segment, i) => { return pMap(segments, async (segment, i) => {
@ -150,8 +188,7 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
const { name: inputFileNameWithoutExt } = parsePath(filePath); const { name: inputFileNameWithoutExt } = parsePath(filePath);
const segFileName = await interpolateSegmentFileName({ const segFileName = await interpolateOutFileName(template, {
template,
epochMs, epochMs,
segNum, segNum,
segNumPadded, segNumPadded,
@ -164,28 +201,56 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
tags: Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)])), tags: Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)])),
}); });
// Now split the path by its separator, so we can check the actual file name (last path seg) return maybeTruncatePath(segFileName, safeOutputFileName);
const pathSegs = segFileName.split(pathSep);
if (pathSegs.length === 0) return '';
const [lastSeg] = pathSegs.slice(-1);
const rest = pathSegs.slice(0, -1);
return [
...rest,
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
safeOutputFileName ? lastSeg!.slice(0, 200) : lastSeg,
].join(pathSep);
}, { concurrency: 5 }); }, { concurrency: 5 });
} }
let outSegFileNames = await generate({ template: desiredTemplate, forceSafeOutputFileName: false }); let fileNames = await generate({ template: desiredTemplate, forceSafeOutputFileName: false });
const outSegProblems = getOutSegProblems({ fileNames: outSegFileNames, filePath, outputDir, safeOutputFileName }); const problems = getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName });
if (outSegProblems.error != null) { if (problems.error != null) {
outSegFileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true }); fileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true });
} }
return { outSegFileNames, outSegProblems }; return { fileNames, problems };
} }
export type GenerateOutSegFileNames = (a: { segments?: SegmentToExport[], template: string }) => ReturnType<typeof generateOutSegFileNames>; export type GenerateOutFileNames = (a: { template: string }) => Promise<{
fileNames: string[],
problems: {
error: string | undefined;
sameAsInputFileNameWarning: boolean;
},
}>;
export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName }: {
template: string,
isCustomFormatSelected: boolean,
fileFormat: string,
filePath: string,
outputDir: string,
safeOutputFileName: boolean,
}) {
async function generate(template: string) {
const epochMs = Date.now();
const { name: inputFileNameWithoutExt } = parsePath(filePath);
const fileName = await interpolateOutFileName(template, {
epochMs,
inputFileNameWithoutExt,
ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }),
});
return maybeTruncatePath(fileName, safeOutputFileName);
}
let fileName = await generate(desiredTemplate);
const problems = getTemplateProblems({ fileNames: [fileName], filePath, outputDir, safeOutputFileName });
if (problems.error != null) {
fileName = await generate(defaultMergedFileTemplate);
}
return { fileNames: [fileName], problems };
}

View File

@ -114,7 +114,7 @@ export const isMov = (format: string | undefined) => format != null && ['ismv',
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined; type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: { function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: {
stream: FFprobeStream, outputIndex: number, outFormat: string, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined stream: FFprobeStream, outputIndex: number, outFormat: string | undefined, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined
}) { }) {
let args: string[] = []; let args: string[] = [];
@ -157,7 +157,7 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
// ffmpeg cannot encode pcm_bluray // ffmpeg cannot encode pcm_bluray
if (stream.codec_name === 'pcm_bluray' && outFormat !== 'mpegts') { if (stream.codec_name === 'pcm_bluray' && outFormat !== 'mpegts') {
addCodecArgs('pcm_s24le'); addCodecArgs('pcm_s24le');
} else if (stream.codec_name === 'pcm_dvd' && ['matroska', 'mov'].includes(outFormat)) { } else if (stream.codec_name === 'pcm_dvd' && outFormat != null && ['matroska', 'mov'].includes(outFormat)) {
// https://github.com/mifi/lossless-cut/discussions/2092 // https://github.com/mifi/lossless-cut/discussions/2092
// coolitnow-partial.vob // coolitnow-partial.vob
// https://superuser.com/questions/1272614/use-ffmpeg-to-merge-mpeg2-files-with-pcm-dvd-audio // https://superuser.com/questions/1272614/use-ffmpeg-to-merge-mpeg2-files-with-pcm-dvd-audio
@ -204,7 +204,7 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
} }
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }: { export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }: {
startIndex?: number, outFormat: string, allFilesMeta, copyFileStreams: { streamIds: number[], path: string }[], manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn, startIndex?: number, outFormat: string | undefined, allFilesMeta, copyFileStreams: { streamIds: number[], path: string }[], manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn,
}) { }) {
let args: string[] = []; let args: string[] = [];
let outputIndex = startIndex; let outputIndex = startIndex;

View File

@ -72,6 +72,7 @@ export interface Config {
preserveMetadataOnMerge: boolean, preserveMetadataOnMerge: boolean,
simpleMode: boolean, simpleMode: boolean,
outSegTemplate: string | undefined, outSegTemplate: string | undefined,
mergedFileTemplate: string | undefined,
keyboardSeekAccFactor: number, keyboardSeekAccFactor: number,
keyboardNormalSeekSpeed: number, keyboardNormalSeekSpeed: number,
keyboardSeekSpeed2: number, keyboardSeekSpeed2: number,