mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
parent
b7b47c8516
commit
624efa7dbc
@ -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
|
||||
|
||||
|
@ -116,6 +116,7 @@ const defaults: Config = {
|
||||
preserveMetadataOnMerge: false,
|
||||
simpleMode: true,
|
||||
outSegTemplate: undefined,
|
||||
mergedFileTemplate: undefined,
|
||||
keyboardSeekAccFactor: 1.03,
|
||||
keyboardNormalSeekSpeed: 1,
|
||||
keyboardSeekSpeed2: 10,
|
||||
|
@ -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<string>();
|
||||
|
||||
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() {
|
||||
/>
|
||||
</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}>
|
||||
{mainStreams && filePath != null && (
|
||||
|
@ -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<SetStateAction<number | undefined>>,
|
||||
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({
|
||||
<td />
|
||||
</tr>
|
||||
|
||||
{canEditTemplate && (
|
||||
{canEditSegTemplate && (
|
||||
<tr>
|
||||
<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>
|
||||
<HelpIcon onClick={onOutSegTemplateHelpPress} />
|
||||
@ -282,14 +287,11 @@ function ExportConfirm({
|
||||
|
||||
{willMerge && (
|
||||
<tr>
|
||||
<td>
|
||||
{t('Merged output file name:')}
|
||||
<td colSpan={2}>
|
||||
<FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
|
||||
</td>
|
||||
<td>
|
||||
<MergedOutFileName mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} />
|
||||
</td>
|
||||
<td>
|
||||
<HelpIcon onClick={() => showHelpText({ text: t('Name of the merged/concatenated output file when concatenating multiple segments.') })} />
|
||||
<HelpIcon onClick={onMergedFileTemplateHelpPress} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
@ -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<string>();
|
||||
const [outSegProblems, setOutSegProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
|
||||
const [outSegFileNames, setOutSegFileNames] = useState<string[]>();
|
||||
const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
|
||||
const [fileNames, setFileNames] = useState<string[]>();
|
||||
const [shown, setShown] = useState<boolean>();
|
||||
const inputRef = useRef<HTMLInputElement>(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: (
|
||||
<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>
|
||||
),
|
||||
});
|
||||
}, [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<ChangeEventHandler<HTMLInputElement>>((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 (
|
||||
<motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : 0 }}>
|
||||
<div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div>
|
||||
<motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : '0 0 .3em 0' }}>
|
||||
<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' }}>
|
||||
{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' }} />}
|
||||
</HighlightedText>
|
||||
)}
|
||||
@ -137,28 +154,28 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
|
||||
<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" />
|
||||
{!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 style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
||||
{`${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')} />
|
||||
{['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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{outSegProblems.error != null && (
|
||||
{problems.error != null && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{outSegProblems.error == null && outSegProblems.sameAsInputFileNameWarning && (
|
||||
{problems.error == null && problems.sameAsInputFileNameWarning && (
|
||||
<div style={{ marginBottom: '1em' }}>
|
||||
<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!')}
|
||||
@ -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' }}>
|
||||
{Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
||||
</Select>
|
||||
Minimum numeric padded length
|
||||
{t('Minimum numeric padded length')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -194,4 +211,4 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(OutSegTemplateEditor);
|
||||
export default memo(FileNameTemplateEditor);
|
@ -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);
|
@ -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 });
|
||||
|
@ -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 });
|
||||
|
@ -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,
|
||||
|
@ -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<string>();
|
||||
|
||||
// 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<string, string>
|
||||
}) {
|
||||
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<string, string>,
|
||||
}>) {
|
||||
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<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 };
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user