diff --git a/import-export.md b/import-export.md index b9aaa94d..61cfc080 100644 --- a/import-export.md +++ b/import-export.md @@ -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: -| Variable | Output | -| -------------- | - | -| `${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`). -| `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`). -| `${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}`. -| `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`). -| `${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`). -| `${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`). -| `${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 ?? ''}` +| Avail when merging? | Variable | Output | +| - | - | - | +| ✅ | `${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`). +| ✅ | `${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}` | Number of the segment, padded string (e.g. `01`, `02` or `42`). +| | `${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_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_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 ?? ''}` -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 diff --git a/src/main/configStore.ts b/src/main/configStore.ts index 692eddc8..b17818fe 100644 --- a/src/main/configStore.ts +++ b/src/main/configStore.ts @@ -116,6 +116,7 @@ const defaults: Config = { preserveMetadataOnMerge: false, simpleMode: true, outSegTemplate: undefined, + mergedFileTemplate: undefined, keyboardSeekAccFactor: 1.03, keyboardNormalSeekSpeed: 1, keyboardSeekSpeed2: 10, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index b010124c..d7659fa6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -66,7 +66,7 @@ import { getOutPath, getSuffixedOutPath, handleError, getOutDir, isStoreBuild, dragPreventer, 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, isMuxNotSupported, getDownloadMediaOutPath, @@ -79,7 +79,7 @@ import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenActio import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; 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 BigWaveform from './components/BigWaveform'; @@ -136,7 +136,6 @@ function App() { const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false); const [exportConfirmVisible, setExportConfirmVisible] = useState(false); const [cacheBuster, setCacheBuster] = useState(0); - const [mergedOutFileName, setMergedOutFileName] = useState(); const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState(); @@ -167,7 +166,7 @@ function App() { const allUserSettings = useUserSettingsRoot(); 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; const { working, setWorking, workingRef, abortWorking } = useLoading(); @@ -188,6 +187,7 @@ function App() { }, [customFfPath]); const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate; + const mergedFileTemplateOrDefault = mergedFileTemplate || defaultMergedFileTemplate; useEffect(() => { const l = language || fallbackLng; @@ -537,15 +537,6 @@ function App() { const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow }); 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(() => { console.log('State reset'); const video = videoRef.current; @@ -587,11 +578,10 @@ function App() { setActiveSubtitleStreamIndex(undefined); setHideMediaSourcePlayer(false); setExportConfirmVisible(false); - resetMergedOutFileName(); setOutputPlaybackRateState(1); 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(() => { @@ -805,10 +795,11 @@ function App() { shortestFlag, effectiveExportMode, outSegTemplate, + mergedFileTemplate, }; 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 state = { ...commonSettings, ...reportState }; @@ -961,18 +952,19 @@ function App() { }, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]); 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 }); }, [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 willMerge = segmentsToExport.length > 1 && autoMerge; - const mergedOutFilePath = useMemo(() => ( - mergedOutFileName != null ? getOutPath({ customOutDir, filePath, fileName: mergedOutFileName }) : undefined - ), [customOutDir, filePath, mergedOutFileName]); - const onExportConfirm = useCallback(async () => { invariant(filePath != null); @@ -1010,7 +1002,7 @@ function App() { 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) { console.warn('Output segments file name invalid, using default instead', outSegFileNames); } @@ -1040,12 +1032,25 @@ function App() { detectedFps, }); + let mergedOutFilePath: string | undefined; + if (willMerge) { + console.log('mergedFileTemplateOrDefault', mergedFileTemplateOrDefault); + setCutProgress(0); setWorking({ text: i18n.t('Merging') }); 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({ customOutDir, outFormat: fileFormat, @@ -1089,7 +1094,7 @@ function App() { 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); if (!hideAllNotifications) { showOsNotification(i18n.t('Export finished')); @@ -1097,8 +1102,6 @@ function App() { } if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog(); - - resetMergedOutFileName(); } catch (err) { if (isExecaError(err)) { if (err.killed) { @@ -1129,7 +1132,7 @@ function App() { setWorking(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 () => { if (!filePath) return; @@ -2586,7 +2589,7 @@ function App() { /> - + setStreamsSelectorShown(false)} maxWidth={1000}> {mainStreams && filePath != null && ( diff --git a/src/renderer/src/components/ExportConfirm.tsx b/src/renderer/src/components/ExportConfirm.tsx index 98487976..1fde6cc7 100644 --- a/src/renderer/src/components/ExportConfirm.tsx +++ b/src/renderer/src/components/ExportConfirm.tsx @@ -12,11 +12,10 @@ import ExportModeButton from './ExportModeButton'; import PreserveMovDataButton from './PreserveMovDataButton'; import MovFastStartButton from './MovFastStartButton'; import ToggleExportConfirm from './ToggleExportConfirm'; -import OutSegTemplateEditor from './OutSegTemplateEditor'; +import FileNameTemplateEditor from './FileNameTemplateEditor'; import HighlightedText, { highlightedTextStyle } from './HighlightedText'; import Select from './Select'; import Switch from './Switch'; -import MergedOutFileName from './MergedOutFileName'; import { primaryTextColor } from '../colors'; import { withBlur } from '../util'; @@ -25,7 +24,7 @@ import { isMov as ffmpegIsMov } from '../util/streams'; import useUserSettings from '../hooks/useUserSettings'; import styles from './ExportConfirm.module.css'; import { InverseCutSegment, SegmentToExport } from '../types'; -import { GenerateOutSegFileNames } from '../util/outputNameTemplate'; +import { defaultMergedFileTemplate, defaultOutSegTemplate, GenerateOutFileNames } from '../util/outputNameTemplate'; import { FFprobeStream } from '../../../../ffprobe'; import { AvoidNegativeTs } from '../../../../types'; import TextInput from './TextInput'; @@ -55,13 +54,14 @@ function ExportConfirm({ onShowStreamsSelectorClick, outSegTemplate, setOutSegTemplate, + mergedFileTemplate, + setMergedFileTemplate, generateOutSegFileNames, + generateMergedFileNames, currentSegIndexSafe, nonFilteredSegmentsOrInverse, mainCopiedThumbnailStreams, needSmartCut, - mergedOutFileName, - setMergedOutFileName, smartCutBitrate, setSmartCutBitrate, toggleSettings, @@ -81,13 +81,14 @@ function ExportConfirm({ onShowStreamsSelectorClick: () => void, outSegTemplate: string, setOutSegTemplate: (a: string) => void, - generateOutSegFileNames: GenerateOutSegFileNames, + mergedFileTemplate: string, + setMergedFileTemplate: (a: string) => void, + generateOutSegFileNames: GenerateOutFileNames, + generateMergedFileNames: GenerateOutFileNames, currentSegIndexSafe: number, nonFilteredSegmentsOrInverse: InverseCutSegment[], mainCopiedThumbnailStreams: FFprobeStream[], needSmartCut: boolean, - mergedOutFileName: string | undefined, - setMergedOutFileName: (a: string) => void, smartCutBitrate: number | undefined, setSmartCutBitrate: Dispatch>, 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 }) }); }, [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(() => { toast.fire({ icon: 'info', timer: 10000, text: exportModeDescription }); }, [exportModeDescription]); @@ -171,7 +176,7 @@ function ExportConfirm({ toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') }); }, [t]); - const canEditTemplate = !willMerge || !autoDeleteMergedSegments; + const canEditSegTemplate = !willMerge || !autoDeleteMergedSegments; const handleSmartCutBitrateToggle = useCallback((checked: boolean) => { setSmartCutBitrate(() => (checked ? undefined : 10000)); @@ -269,10 +274,10 @@ function ExportConfirm({ - {canEditTemplate && ( + {canEditSegTemplate && ( - + @@ -282,14 +287,11 @@ function ExportConfirm({ {willMerge && ( - - {t('Merged output file name:')} + + - - - - showHelpText({ text: t('Name of the merged/concatenated output file when concatenating multiple segments.') })} /> + )} diff --git a/src/renderer/src/components/OutSegTemplateEditor.tsx b/src/renderer/src/components/FileNameTemplateEditor.tsx similarity index 66% rename from src/renderer/src/components/OutSegTemplateEditor.tsx rename to src/renderer/src/components/FileNameTemplateEditor.tsx index 4c98c062..854011d4 100644 --- a/src/renderer/src/components/OutSegTemplateEditor.tsx +++ b/src/renderer/src/components/FileNameTemplateEditor.tsx @@ -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 i18n from 'i18next'; import { useTranslation } from 'react-i18next'; @@ -9,7 +9,7 @@ import { FaEdit } from 'react-icons/fa'; import { ReactSwal } from '../swal'; 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 Switch from './Switch'; import Select from './Select'; @@ -17,21 +17,32 @@ import TextInput from './TextInput'; const electron = window.require('electron'); -const formatVariable = (variable) => `\${${variable}}`; + +const formatVariable = (variable: string) => `\${${variable}}`; const extVariableFormatted = formatVariable(extVariable); const segTagsExample = `${segTagsVariable}.XX`; -function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: { - outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number, -}) { +function FileNameTemplateEditor(opts: { + 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 [text, setText] = useState(outSegTemplate); + const [text, setText] = useState(templateIn); const [debouncedText] = useDebounce(text, 500); const [validText, setValidText] = useState(); - const [outSegProblems, setOutSegProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false }); - const [outSegFileNames, setOutSegFileNames] = useState(); + const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false }); + const [fileNames, setFileNames] = useState(); const [shown, setShown] = useState(); const inputRef = useRef(null); @@ -48,59 +59,64 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe (async () => { try { - // console.time('generateOutSegFileNames') - const outSegs = await generateOutSegFileNames({ template: debouncedText }); + // console.time('generateFileNames') + const outSegs = await generateFileNames({ template: debouncedText }); // console.timeEnd('generateOutSegFileNames') if (abortController.signal.aborted) return; - setOutSegFileNames(outSegs.outSegFileNames); - setOutSegProblems(outSegs.outSegProblems); - setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined); + setFileNames(outSegs.fileNames); + setProblems(outSegs.problems); + setValidText(outSegs.problems.error == null ? debouncedText : undefined); } catch (err) { console.error(err); setValidText(undefined); - setOutSegProblems({ error: err instanceof Error ? err.message : String(err) }); + setProblems({ error: err instanceof Error ? err.message : String(err) }); } })(); 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 const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted); - const onAllSegmentsPreviewPress = useCallback(() => { - if (outSegFileNames == null) return; + const onAllFilesPreviewPress = useCallback(() => { + if (fileNames == null) return; ReactSwal.fire({ - title: t('Resulting segment file names', { count: outSegFileNames.length }), + title: t('Resulting segment file names', { count: fileNames.length }), html: (
- {outSegFileNames.map((f) =>
{f}
)} + {fileNames.map((f) =>
{f}
)}
), }); - }, [outSegFileNames, t]); + }, [fileNames, t]); useEffect(() => { - if (validText != null) setOutSegTemplate(validText); - }, [validText, setOutSegTemplate]); + if (validText != null) setTemplate(validText); + }, [validText, setTemplate]); const reset = useCallback(() => { - setOutSegTemplate(defaultOutSegTemplate); - setText(defaultOutSegTemplate); - }, [setOutSegTemplate]); + setTemplate(defaultTemplate); + setText(defaultTemplate); + }, [defaultTemplate, setTemplate]); const onHideClick = useCallback(() => { - if (outSegProblems.error == null) setShown(false); - }, [outSegProblems.error]); + if (problems.error == null) setShown(false); + }, [problems.error]); const onShowClick = useCallback(() => { if (!shown) setShown(true); }, [shown]); - const onTextChange = useCallback((e) => setText(e.target.value), []); + const onTextChange = useCallback>((e) => setText(e.target.value), []); - const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning; - const needToShow = shown || gotImportantMessage; + const haveImportantMessage = problems.error != null || problems.sameAsInputFileNameWarning; + const needToShow = shown || haveImportantMessage; const onVariableClick = useCallback((variable: string) => { const input = inputRef.current; @@ -115,12 +131,13 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe }, [text]); return ( - -
{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}
+ +
{fileNames != null && (mergeMode ? t('Merged output file name:') : t('Output name(s):', { count: fileNames.length }))}
- {outSegFileNames != null && ( + {fileNames != null && ( - {outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'} + {/* eslint-disable-next-line react/destructuring-assignment */} + {('currentSegIndexSafe' in opts ? fileNames[opts.currentSegIndexSafe] : undefined) || fileNames[0] || '-'} {!needToShow && } )} @@ -137,28 +154,28 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
- {outSegFileNames != null && } + {!mergeMode && fileNames != null && } - {!gotImportantMessage && } + {!haveImportantMessage && }
{`${i18n.t('Variables')}:`} 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) => ( onVariableClick(variable)}>{variable} ))}
- {outSegProblems.error != null && ( + {problems.error != null && (
- {outSegProblems.error} + {problems.error}
)} - {outSegProblems.error == null && outSegProblems.sameAsInputFileNameWarning && ( + {problems.error == null && problems.sameAsInputFileNameWarning && (
{' '} {i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')} @@ -177,7 +194,7 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe - Minimum numeric padded length + {t('Minimum numeric padded length')}
)} @@ -194,4 +211,4 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe ); } -export default memo(OutSegTemplateEditor); +export default memo(FileNameTemplateEditor); diff --git a/src/renderer/src/components/MergedOutFileName.tsx b/src/renderer/src/components/MergedOutFileName.tsx deleted file mode 100644 index 6ff58621..00000000 --- a/src/renderer/src/components/MergedOutFileName.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { memo } from 'react'; - -import TextInput from './TextInput'; - - -function MergedOutFileName({ mergedOutFileName, setMergedOutFileName }: { mergedOutFileName: string | undefined, setMergedOutFileName: (a: string) => void }) { - return ( -
- setMergedOutFileName(e.target.value)} style={{ textAlign: 'right' }} /> -
- ); -} - -export default memo(MergedOutFileName); diff --git a/src/renderer/src/ffmpeg.ts b/src/renderer/src/ffmpeg.ts index 1cdf8865..05091307 100644 --- a/src/renderer/src/ffmpeg.ts +++ b/src/renderer/src/ffmpeg.ts @@ -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; try { const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 }); diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index 0aa5810e..037b6367 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -82,7 +82,20 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea 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 }: { - 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 }; @@ -500,7 +513,19 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea } }, [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); if (await shouldSkipExistingFile(mergedOutFilePath)) return; @@ -508,6 +533,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames }); const metadataFromPath = segmentPaths[0]; + invariant(metadataFromPath != null); // need to re-read streams because may have changed const { streams } = await readFileMeta(metadataFromPath); await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge }); diff --git a/src/renderer/src/hooks/useUserSettingsRoot.ts b/src/renderer/src/hooks/useUserSettingsRoot.ts index 59204b49..b204e428 100644 --- a/src/renderer/src/hooks/useUserSettingsRoot.ts +++ b/src/renderer/src/hooks/useUserSettingsRoot.ts @@ -97,6 +97,8 @@ export default () => { 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 [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor')); useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]); const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed')); @@ -224,6 +226,8 @@ export default () => { setSimpleMode, outSegTemplate, setOutSegTemplate, + mergedFileTemplate, + setMergedFileTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, diff --git a/src/renderer/src/util/outputNameTemplate.ts b/src/renderer/src/util/outputNameTemplate.ts index 48c2d00c..beffa4cd 100644 --- a/src/renderer/src/util/outputNameTemplate.ts +++ b/src/renderer/src/util/outputNameTemplate.ts @@ -18,8 +18,11 @@ export const segTagsVariable = 'SEG_TAGS'; const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path'); -function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName }: { - fileNames: string[], filePath: string, outputDir: string, safeOutputFileName: boolean +function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName }: { + fileNames: string[], + filePath: string, + outputDir: string, + safeOutputFileName: boolean, }) { let error: string | undefined; let sameAsInputFileNameWarning = false; @@ -30,7 +33,7 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName break; } - const invalidChars = new Set(); + const invalidChars = new Set(); // 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) @@ -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 }); } @@ -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 // eslint-disable-next-line no-template-curly-in-string 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 }: { - template: string, epochMs: number, inputFileNameWithoutExt: string, segSuffix: string, ext: string, segNum: number, segNumPadded: string, segLabel: string, cutFrom: string, cutTo: string, tags: Record -}) { +async function interpolateOutFileName(template: string, { epochMs, inputFileNameWithoutExt, ext, segSuffix, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: { + epochMs: number, + inputFileNameWithoutExt: string, + ext: string, +} & Partial<{ + segSuffix: string, + segNum: number, + segNumPadded: string, + segLabel: string, + cutFrom: string, + cutTo: string, + tags: Record, +}>) { const context = { FILENAME: inputFileNameWithoutExt, [segSuffixVariable]: segSuffix, @@ -114,7 +129,7 @@ async function interpolateSegmentFileName({ template, epochMs, inputFileNameWith EPOCH_MS: epochMs, CUT_FROM: cutFrom, CUT_TO: cutTo, - [segTagsVariable]: { + [segTagsVariable]: tags && { // allow both original case and uppercase ...tags, ...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; } +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 }: { - 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(); return pMap(segments, async (segment, i) => { @@ -150,8 +188,7 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl const { name: inputFileNameWithoutExt } = parsePath(filePath); - const segFileName = await interpolateSegmentFileName({ - template, + const segFileName = await interpolateOutFileName(template, { epochMs, segNum, 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)])), }); - // Now split the path by its separator, so we can check the actual file name (last path seg) - 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); + return maybeTruncatePath(segFileName, safeOutputFileName); }, { 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 }); - if (outSegProblems.error != null) { - outSegFileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true }); + const problems = getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName }); + if (problems.error != null) { + fileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true }); } - return { outSegFileNames, outSegProblems }; + return { fileNames, problems }; } -export type GenerateOutSegFileNames = (a: { segments?: SegmentToExport[], template: string }) => ReturnType; +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 }; +} diff --git a/src/renderer/src/util/streams.ts b/src/renderer/src/util/streams.ts index 11feb4d0..5d9018e6 100644 --- a/src/renderer/src/util/streams.ts +++ b/src/renderer/src/util/streams.ts @@ -114,7 +114,7 @@ export const isMov = (format: string | undefined) => format != null && ['ismv', type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | 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[] = []; @@ -157,7 +157,7 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi // ffmpeg cannot encode pcm_bluray if (stream.codec_name === 'pcm_bluray' && outFormat !== 'mpegts') { 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 // coolitnow-partial.vob // 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 }: { - 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 outputIndex = startIndex; diff --git a/types.ts b/types.ts index 7a482ba0..7d36e1fc 100644 --- a/types.ts +++ b/types.ts @@ -72,6 +72,7 @@ export interface Config { preserveMetadataOnMerge: boolean, simpleMode: boolean, outSegTemplate: string | undefined, + mergedFileTemplate: string | undefined, keyboardSeekAccFactor: number, keyboardNormalSeekSpeed: number, keyboardSeekSpeed2: number,