mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 02:12:30 +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:
|
When exporting multiple segments as separate files, LosslessCut offers you the ability to specify how the output files will be named in sequence using a *template string*. The template string is evaluated as a [JavaScript template string](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals), so you can use JavaScript syntax inside of it. The following variables are available in the template to customize the filenames:
|
||||||
|
|
||||||
| Variable | Output |
|
| Avail when merging? | Variable | Output |
|
||||||
| -------------- | - |
|
| - | - | - |
|
||||||
| `${FILENAME}` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`).
|
| ✅ | `${FILENAME}` | The original filename *without the extension* (e.g. `Beach Trip` for a file named `Beach Trip.mp4`).
|
||||||
| `${EXT}` | The extension of the file (e.g.: `.mp4`, `.mkv`).
|
| ✅ | `${EXT}` | The extension of the file (e.g.: `.mp4`, `.mkv`).
|
||||||
| `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`).
|
| ✅ | `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`). Useful to generate a unique file name on every export to prevent accidental overwrite.
|
||||||
| `${SEG_NUM_INT}` | Number of the segment, as a raw integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`.
|
| | `${SEG_NUM}` | Number of the segment, padded string (e.g. `01`, `02` or `42`).
|
||||||
| `${EPOCH_MS}` | Number of milliseconds since epoch (e.g. `1680852771465`).
|
| | `${SEG_NUM_INT}` | Number of the segment, as a raw integer (e.g. `1`, `2` or `42`). Can be used with numeric arithmetics, e.g. `${SEG_NUM_INT+100}`.
|
||||||
| `${SEG_LABEL}` | The label of the segment (e.g. `Getting_Lunch`).
|
| | `${SEG_LABEL}` | The label of the segment (e.g. `Getting_Lunch`).
|
||||||
| `${SEG_SUFFIX}` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment number prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`).
|
| | `${SEG_SUFFIX}` | If a label exists for this segment, the label will be used, prepended by `-`. Otherwise, the segment number prepended by `-seg` will be used (e.g. `-Getting_Lunch`, `-seg1`).
|
||||||
| `${CUT_FROM}` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`).
|
| | `${CUT_FROM}` | The timestamp for the beginning of the segment in `hh.mm.ss.sss` format (e.g. `00.00.27.184`).
|
||||||
| `${CUT_TO}` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`).
|
| | `${CUT_TO}` | The timestamp for the ending of the segment in `hh.mm.ss.sss` format (e.g. `00.00.28.000`).
|
||||||
| `${SEG_TAGS.XX}` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will return the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}`
|
| | `${SEG_TAGS.XX}` | Allows you to retrieve the tags for a given segment by name. If a tag is called foo, it can be accessed with `${SEG_TAGS.foo}`. Note that if the tag does not exist, it will return the text `undefined`. You can work around this as follows: `${SEG_TAGS.foo ?? ''}`
|
||||||
|
|
||||||
Your files must always include at least one unique identifer (such as `${SEG_NUM}` or `${CUT_FROM}`), and they should end in `${EXT}` (or else players might not recognise the files). For instance, to achieve a filename sequence of `Beach Trip - 1.mp4`, `Beach Trip - 2.mp4`, `Beach Trip - 3.mp4`, your format should read `${FILENAME} - ${SEG_NUM}${EXT}`
|
Your files must always include at least one unique identifer (such as `${SEG_NUM}` or `${CUT_FROM}`), and it should end in `${EXT}` (or else players might not recognise the files). For instance, to achieve a filename sequence of `Beach Trip - 1.mp4`, `Beach Trip - 2.mp4`, `Beach Trip - 3.mp4`, your format should read `${FILENAME} - ${SEG_NUM}${EXT}`
|
||||||
|
|
||||||
## Export project formats
|
## Export project formats
|
||||||
|
|
||||||
|
@ -116,6 +116,7 @@ const defaults: Config = {
|
|||||||
preserveMetadataOnMerge: false,
|
preserveMetadataOnMerge: false,
|
||||||
simpleMode: true,
|
simpleMode: true,
|
||||||
outSegTemplate: undefined,
|
outSegTemplate: undefined,
|
||||||
|
mergedFileTemplate: undefined,
|
||||||
keyboardSeekAccFactor: 1.03,
|
keyboardSeekAccFactor: 1.03,
|
||||||
keyboardNormalSeekSpeed: 1,
|
keyboardNormalSeekSpeed: 1,
|
||||||
keyboardSeekSpeed2: 10,
|
keyboardSeekSpeed2: 10,
|
||||||
|
@ -66,7 +66,7 @@ import {
|
|||||||
getOutPath, getSuffixedOutPath, handleError, getOutDir,
|
getOutPath, getSuffixedOutPath, handleError, getOutDir,
|
||||||
isStoreBuild, dragPreventer,
|
isStoreBuild, dragPreventer,
|
||||||
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile,
|
||||||
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, getOutFileExtension, getSuffixedFileName, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
deleteFiles, isOutOfSpaceError, readFileSize, readFileSizes, checkFileSizes, setDocumentTitle, mustDisallowVob, readVideoTs, readDirRecursively, getImportProjectType,
|
||||||
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
|
calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString,
|
||||||
isMuxNotSupported,
|
isMuxNotSupported,
|
||||||
getDownloadMediaOutPath,
|
getDownloadMediaOutPath,
|
||||||
@ -79,7 +79,7 @@ import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenActio
|
|||||||
import { openSendReportDialog } from './reporting';
|
import { openSendReportDialog } from './reporting';
|
||||||
import { fallbackLng } from './i18n';
|
import { fallbackLng } from './i18n';
|
||||||
import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
|
import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments';
|
||||||
import { generateOutSegFileNames as generateOutSegFileNamesRaw, defaultOutSegTemplate } from './util/outputNameTemplate';
|
import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultMergedFileTemplate } from './util/outputNameTemplate';
|
||||||
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
|
import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants';
|
||||||
import BigWaveform from './components/BigWaveform';
|
import BigWaveform from './components/BigWaveform';
|
||||||
|
|
||||||
@ -136,7 +136,6 @@ function App() {
|
|||||||
const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false);
|
const [hideMediaSourcePlayer, setHideMediaSourcePlayer] = useState(false);
|
||||||
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
const [exportConfirmVisible, setExportConfirmVisible] = useState(false);
|
||||||
const [cacheBuster, setCacheBuster] = useState(0);
|
const [cacheBuster, setCacheBuster] = useState(0);
|
||||||
const [mergedOutFileName, setMergedOutFileName] = useState<string>();
|
|
||||||
|
|
||||||
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
const { fileFormat, setFileFormat, detectedFileFormat, setDetectedFileFormat, isCustomFormatSelected } = useFileFormatState();
|
||||||
|
|
||||||
@ -167,7 +166,7 @@ function App() {
|
|||||||
const allUserSettings = useUserSettingsRoot();
|
const allUserSettings = useUserSettingsRoot();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
|
captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, autoMerge, timecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, askBeforeClose, enableAskForImportChapters, enableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, wheelSensitivity, invertTimelineScroll, language, ffmpegExperimental, hideNotifications, hideOsNotifications, autoLoadTimecode, autoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, mergedFileTemplate, setMergedFileTemplate, keyboardSeekAccFactor, keyboardNormalSeekSpeed, keyboardSeekSpeed2, keyboardSeekSpeed3, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, segmentsToChaptersOnly, keyBindings, setKeyBindings, resetKeyBindings, enableSmartCut, customFfPath, storeProjectInWorkingDir, setStoreProjectInWorkingDir, enableOverwriteOutput, mouseWheelZoomModifierKey, captureFrameMethod, captureFrameQuality, captureFrameFileNameFormat, enableNativeHevc, cleanupChoices, setCleanupChoices, darkMode, setDarkMode, preferStrongColors, outputFileNameMinZeroPadding, cutFromAdjustmentFrames,
|
||||||
} = allUserSettings;
|
} = allUserSettings;
|
||||||
|
|
||||||
const { working, setWorking, workingRef, abortWorking } = useLoading();
|
const { working, setWorking, workingRef, abortWorking } = useLoading();
|
||||||
@ -188,6 +187,7 @@ function App() {
|
|||||||
}, [customFfPath]);
|
}, [customFfPath]);
|
||||||
|
|
||||||
const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate;
|
const outSegTemplateOrDefault = outSegTemplate || defaultOutSegTemplate;
|
||||||
|
const mergedFileTemplateOrDefault = mergedFileTemplate || defaultMergedFileTemplate;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const l = language || fallbackLng;
|
const l = language || fallbackLng;
|
||||||
@ -537,15 +537,6 @@ function App() {
|
|||||||
const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow });
|
const { neighbouringKeyFrames, findNearestKeyFrameTime } = useKeyframes({ keyframesEnabled, filePath, commandedTime, videoStream: activeVideoStream, detectedFps, ffmpegExtractWindow });
|
||||||
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, duration });
|
const { waveforms } = useWaveform({ darkMode, filePath, relevantTime, waveformEnabled, audioStream: activeAudioStream, ffmpegExtractWindow, duration });
|
||||||
|
|
||||||
const resetMergedOutFileName = useCallback(() => {
|
|
||||||
if (fileFormat == null || filePath == null) return;
|
|
||||||
const ext = getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath });
|
|
||||||
const outFileName = getSuffixedFileName(filePath, `cut-merged-${Date.now()}${ext}`);
|
|
||||||
setMergedOutFileName(outFileName);
|
|
||||||
}, [fileFormat, filePath, isCustomFormatSelected]);
|
|
||||||
|
|
||||||
useEffect(() => resetMergedOutFileName(), [resetMergedOutFileName]);
|
|
||||||
|
|
||||||
const resetState = useCallback(() => {
|
const resetState = useCallback(() => {
|
||||||
console.log('State reset');
|
console.log('State reset');
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@ -587,11 +578,10 @@ function App() {
|
|||||||
setActiveSubtitleStreamIndex(undefined);
|
setActiveSubtitleStreamIndex(undefined);
|
||||||
setHideMediaSourcePlayer(false);
|
setHideMediaSourcePlayer(false);
|
||||||
setExportConfirmVisible(false);
|
setExportConfirmVisible(false);
|
||||||
resetMergedOutFileName();
|
|
||||||
setOutputPlaybackRateState(1);
|
setOutputPlaybackRateState(1);
|
||||||
|
|
||||||
cancelRenderThumbnails();
|
cancelRenderThumbnails();
|
||||||
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, resetMergedOutFileName, setOutputPlaybackRateState, cancelRenderThumbnails]);
|
}, [videoRef, setCommandedTime, setPlaybackRate, setPlaying, playingRef, playbackModeRef, setCompatPlayerEventId, setDuration, cutSegmentsHistory, clearSegments, setFileFormat, setDetectedFileFormat, setCopyStreamIdsByFile, setThumbnails, setDeselectedSegmentIds, setSubtitlesByStreamId, setOutputPlaybackRateState, cancelRenderThumbnails]);
|
||||||
|
|
||||||
|
|
||||||
const showUnsupportedFileMessage = useCallback(() => {
|
const showUnsupportedFileMessage = useCallback(() => {
|
||||||
@ -805,10 +795,11 @@ function App() {
|
|||||||
shortestFlag,
|
shortestFlag,
|
||||||
effectiveExportMode,
|
effectiveExportMode,
|
||||||
outSegTemplate,
|
outSegTemplate,
|
||||||
|
mergedFileTemplate,
|
||||||
};
|
};
|
||||||
|
|
||||||
openSendReportDialog(err, state);
|
openSendReportDialog(err, state);
|
||||||
}, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, outSegTemplate, rotation, shortestFlag]);
|
}, [commonSettings, copyStreamIdsByFile, cutSegments, effectiveExportMode, externalFilesMeta, fileFormat, filePath, mainFileFormatData, mainStreams, mergedFileTemplate, outSegTemplate, rotation, shortestFlag]);
|
||||||
|
|
||||||
const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => {
|
const openSendConcatReportDialogWithState = useCallback(async (err: unknown, reportState?: object) => {
|
||||||
const state = { ...commonSettings, ...reportState };
|
const state = { ...commonSettings, ...reportState };
|
||||||
@ -961,18 +952,19 @@ function App() {
|
|||||||
}, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]);
|
}, [cleanupFilesWithDialog, isFileOpened, setWorking, workingRef]);
|
||||||
|
|
||||||
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
const generateOutSegFileNames = useCallback(async ({ segments = segmentsToExport, template }: { segments?: SegmentToExport[], template: string }) => {
|
||||||
if (fileFormat == null || outputDir == null || filePath == null) throw new Error();
|
invariant(fileFormat != null && outputDir != null && filePath != null);
|
||||||
return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
|
return generateOutSegFileNamesRaw({ segments, template, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding });
|
||||||
}, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
}, [fileFormat, filePath, formatTimecode, isCustomFormatSelected, maxLabelLength, outputDir, outputFileNameMinZeroPadding, safeOutputFileName, segmentsToExport]);
|
||||||
|
|
||||||
|
const generateMergedFileNames = useCallback(async ({ template }: { template: string }) => {
|
||||||
|
invariant(fileFormat != null && filePath != null);
|
||||||
|
return generateMergedFileNamesRaw({ template, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName });
|
||||||
|
}, [fileFormat, filePath, isCustomFormatSelected, outputDir, safeOutputFileName]);
|
||||||
|
|
||||||
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
|
const closeExportConfirm = useCallback(() => setExportConfirmVisible(false), []);
|
||||||
|
|
||||||
const willMerge = segmentsToExport.length > 1 && autoMerge;
|
const willMerge = segmentsToExport.length > 1 && autoMerge;
|
||||||
|
|
||||||
const mergedOutFilePath = useMemo(() => (
|
|
||||||
mergedOutFileName != null ? getOutPath({ customOutDir, filePath, fileName: mergedOutFileName }) : undefined
|
|
||||||
), [customOutDir, filePath, mergedOutFileName]);
|
|
||||||
|
|
||||||
const onExportConfirm = useCallback(async () => {
|
const onExportConfirm = useCallback(async () => {
|
||||||
invariant(filePath != null);
|
invariant(filePath != null);
|
||||||
|
|
||||||
@ -1010,7 +1002,7 @@ function App() {
|
|||||||
|
|
||||||
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
|
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
|
||||||
|
|
||||||
const { outSegFileNames, outSegProblems } = await generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
|
const { fileNames: outSegFileNames, problems: outSegProblems } = await generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
|
||||||
if (outSegProblems.error != null) {
|
if (outSegProblems.error != null) {
|
||||||
console.warn('Output segments file name invalid, using default instead', outSegFileNames);
|
console.warn('Output segments file name invalid, using default instead', outSegFileNames);
|
||||||
}
|
}
|
||||||
@ -1040,12 +1032,25 @@ function App() {
|
|||||||
detectedFps,
|
detectedFps,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let mergedOutFilePath: string | undefined;
|
||||||
|
|
||||||
if (willMerge) {
|
if (willMerge) {
|
||||||
|
console.log('mergedFileTemplateOrDefault', mergedFileTemplateOrDefault);
|
||||||
|
|
||||||
setCutProgress(0);
|
setCutProgress(0);
|
||||||
setWorking({ text: i18n.t('Merging') });
|
setWorking({ text: i18n.t('Merging') });
|
||||||
|
|
||||||
const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined;
|
const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined;
|
||||||
|
|
||||||
|
const { fileNames, problems } = await generateMergedFileNames({ template: mergedFileTemplateOrDefault });
|
||||||
|
if (problems.error != null) {
|
||||||
|
console.warn('Merged file name invalid, using default instead', fileNames[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [fileName] = fileNames;
|
||||||
|
invariant(fileName != null);
|
||||||
|
mergedOutFilePath = getOutPath({ customOutDir, filePath, fileName });
|
||||||
|
|
||||||
await autoConcatCutSegments({
|
await autoConcatCutSegments({
|
||||||
customOutDir,
|
customOutDir,
|
||||||
outFormat: fileFormat,
|
outFormat: fileFormat,
|
||||||
@ -1089,7 +1094,7 @@ function App() {
|
|||||||
|
|
||||||
if (areWeCutting) notices.push(i18n.t('Cutpoints may be inaccurate.'));
|
if (areWeCutting) notices.push(i18n.t('Cutpoints may be inaccurate.'));
|
||||||
|
|
||||||
const revealPath = willMerge ? mergedOutFilePath : outFiles[0];
|
const revealPath = willMerge && mergedOutFilePath != null ? mergedOutFilePath : outFiles[0];
|
||||||
invariant(revealPath != null);
|
invariant(revealPath != null);
|
||||||
if (!hideAllNotifications) {
|
if (!hideAllNotifications) {
|
||||||
showOsNotification(i18n.t('Export finished'));
|
showOsNotification(i18n.t('Export finished'));
|
||||||
@ -1097,8 +1102,6 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog();
|
if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog();
|
||||||
|
|
||||||
resetMergedOutFileName();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isExecaError(err)) {
|
if (isExecaError(err)) {
|
||||||
if (err.killed) {
|
if (err.killed) {
|
||||||
@ -1129,7 +1132,7 @@ function App() {
|
|||||||
setWorking(undefined);
|
setWorking(undefined);
|
||||||
setCutProgress(undefined);
|
setCutProgress(undefined);
|
||||||
}
|
}
|
||||||
}, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, mergedOutFilePath, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, resetMergedOutFileName, selectedSegmentsOrInverse, segmentsToChapters, invertCutSegments, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]);
|
}, [filePath, numStreamsToCopy, segmentsToExport, haveInvalidSegs, workingRef, setWorking, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, cutMultiple, outputDir, customOutDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, allFilesMeta, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, preserveMetadataOnMerge, movFastStart, avoidNegativeTs, customTagsByFile, paramsByStreamId, detectedFps, willMerge, enableOverwriteOutput, exportConfirmEnabled, mainFileFormatData, mainStreams, exportExtraStreams, areWeCutting, hideAllNotifications, cleanupChoices.cleanupAfterExport, cleanupFilesWithDialog, selectedSegmentsOrInverse, mergedFileTemplateOrDefault, segmentsToChapters, invertCutSegments, generateMergedFileNames, autoConcatCutSegments, autoDeleteMergedSegments, nonCopiedExtraStreams, showOsNotification, handleExportFailed]);
|
||||||
|
|
||||||
const onExportPress = useCallback(async () => {
|
const onExportPress = useCallback(async () => {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
@ -2586,7 +2589,7 @@ function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} smartCutBitrate={smartCutBitrate} setSmartCutBitrate={setSmartCutBitrate} toggleSettings={toggleSettings} />
|
<ExportConfirm areWeCutting={areWeCutting} nonFilteredSegmentsOrInverse={nonFilteredSegmentsOrInverse} selectedSegments={selectedSegmentsOrInverse} segmentsToExport={segmentsToExport} willMerge={willMerge} visible={exportConfirmVisible} onClosePress={closeExportConfirm} onExportConfirm={onExportConfirm} renderOutFmt={renderOutFmt} outputDir={outputDir} numStreamsTotal={numStreamsTotal} numStreamsToCopy={numStreamsToCopy} onShowStreamsSelectorClick={handleShowStreamsSelectorClick} outFormat={fileFormat} setOutSegTemplate={setOutSegTemplate} outSegTemplate={outSegTemplateOrDefault} mergedFileTemplate={mergedFileTemplateOrDefault} setMergedFileTemplate={setMergedFileTemplate} generateOutSegFileNames={generateOutSegFileNames} generateMergedFileNames={generateMergedFileNames} currentSegIndexSafe={currentSegIndexSafe} mainCopiedThumbnailStreams={mainCopiedThumbnailStreams} needSmartCut={needSmartCut} smartCutBitrate={smartCutBitrate} setSmartCutBitrate={setSmartCutBitrate} toggleSettings={toggleSettings} />
|
||||||
|
|
||||||
<Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}>
|
<Sheet visible={streamsSelectorShown} onClosePress={() => setStreamsSelectorShown(false)} maxWidth={1000}>
|
||||||
{mainStreams && filePath != null && (
|
{mainStreams && filePath != null && (
|
||||||
|
@ -12,11 +12,10 @@ import ExportModeButton from './ExportModeButton';
|
|||||||
import PreserveMovDataButton from './PreserveMovDataButton';
|
import PreserveMovDataButton from './PreserveMovDataButton';
|
||||||
import MovFastStartButton from './MovFastStartButton';
|
import MovFastStartButton from './MovFastStartButton';
|
||||||
import ToggleExportConfirm from './ToggleExportConfirm';
|
import ToggleExportConfirm from './ToggleExportConfirm';
|
||||||
import OutSegTemplateEditor from './OutSegTemplateEditor';
|
import FileNameTemplateEditor from './FileNameTemplateEditor';
|
||||||
import HighlightedText, { highlightedTextStyle } from './HighlightedText';
|
import HighlightedText, { highlightedTextStyle } from './HighlightedText';
|
||||||
import Select from './Select';
|
import Select from './Select';
|
||||||
import Switch from './Switch';
|
import Switch from './Switch';
|
||||||
import MergedOutFileName from './MergedOutFileName';
|
|
||||||
|
|
||||||
import { primaryTextColor } from '../colors';
|
import { primaryTextColor } from '../colors';
|
||||||
import { withBlur } from '../util';
|
import { withBlur } from '../util';
|
||||||
@ -25,7 +24,7 @@ import { isMov as ffmpegIsMov } from '../util/streams';
|
|||||||
import useUserSettings from '../hooks/useUserSettings';
|
import useUserSettings from '../hooks/useUserSettings';
|
||||||
import styles from './ExportConfirm.module.css';
|
import styles from './ExportConfirm.module.css';
|
||||||
import { InverseCutSegment, SegmentToExport } from '../types';
|
import { InverseCutSegment, SegmentToExport } from '../types';
|
||||||
import { GenerateOutSegFileNames } from '../util/outputNameTemplate';
|
import { defaultMergedFileTemplate, defaultOutSegTemplate, GenerateOutFileNames } from '../util/outputNameTemplate';
|
||||||
import { FFprobeStream } from '../../../../ffprobe';
|
import { FFprobeStream } from '../../../../ffprobe';
|
||||||
import { AvoidNegativeTs } from '../../../../types';
|
import { AvoidNegativeTs } from '../../../../types';
|
||||||
import TextInput from './TextInput';
|
import TextInput from './TextInput';
|
||||||
@ -55,13 +54,14 @@ function ExportConfirm({
|
|||||||
onShowStreamsSelectorClick,
|
onShowStreamsSelectorClick,
|
||||||
outSegTemplate,
|
outSegTemplate,
|
||||||
setOutSegTemplate,
|
setOutSegTemplate,
|
||||||
|
mergedFileTemplate,
|
||||||
|
setMergedFileTemplate,
|
||||||
generateOutSegFileNames,
|
generateOutSegFileNames,
|
||||||
|
generateMergedFileNames,
|
||||||
currentSegIndexSafe,
|
currentSegIndexSafe,
|
||||||
nonFilteredSegmentsOrInverse,
|
nonFilteredSegmentsOrInverse,
|
||||||
mainCopiedThumbnailStreams,
|
mainCopiedThumbnailStreams,
|
||||||
needSmartCut,
|
needSmartCut,
|
||||||
mergedOutFileName,
|
|
||||||
setMergedOutFileName,
|
|
||||||
smartCutBitrate,
|
smartCutBitrate,
|
||||||
setSmartCutBitrate,
|
setSmartCutBitrate,
|
||||||
toggleSettings,
|
toggleSettings,
|
||||||
@ -81,13 +81,14 @@ function ExportConfirm({
|
|||||||
onShowStreamsSelectorClick: () => void,
|
onShowStreamsSelectorClick: () => void,
|
||||||
outSegTemplate: string,
|
outSegTemplate: string,
|
||||||
setOutSegTemplate: (a: string) => void,
|
setOutSegTemplate: (a: string) => void,
|
||||||
generateOutSegFileNames: GenerateOutSegFileNames,
|
mergedFileTemplate: string,
|
||||||
|
setMergedFileTemplate: (a: string) => void,
|
||||||
|
generateOutSegFileNames: GenerateOutFileNames,
|
||||||
|
generateMergedFileNames: GenerateOutFileNames,
|
||||||
currentSegIndexSafe: number,
|
currentSegIndexSafe: number,
|
||||||
nonFilteredSegmentsOrInverse: InverseCutSegment[],
|
nonFilteredSegmentsOrInverse: InverseCutSegment[],
|
||||||
mainCopiedThumbnailStreams: FFprobeStream[],
|
mainCopiedThumbnailStreams: FFprobeStream[],
|
||||||
needSmartCut: boolean,
|
needSmartCut: boolean,
|
||||||
mergedOutFileName: string | undefined,
|
|
||||||
setMergedOutFileName: (a: string) => void,
|
|
||||||
smartCutBitrate: number | undefined,
|
smartCutBitrate: number | undefined,
|
||||||
setSmartCutBitrate: Dispatch<SetStateAction<number | undefined>>,
|
setSmartCutBitrate: Dispatch<SetStateAction<number | undefined>>,
|
||||||
toggleSettings: () => void,
|
toggleSettings: () => void,
|
||||||
@ -147,6 +148,10 @@ function ExportConfirm({
|
|||||||
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the output segment(s) using special variables.', { count: segmentsToExport.length }) });
|
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the output segment(s) using special variables.', { count: segmentsToExport.length }) });
|
||||||
}, [segmentsToExport.length]);
|
}, [segmentsToExport.length]);
|
||||||
|
|
||||||
|
const onMergedFileTemplateHelpPress = useCallback(() => {
|
||||||
|
toast.fire({ icon: 'info', timer: 10000, text: i18n.t('You can customize the file name of the merged file using special variables.') });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onExportModeHelpPress = useCallback(() => {
|
const onExportModeHelpPress = useCallback(() => {
|
||||||
toast.fire({ icon: 'info', timer: 10000, text: exportModeDescription });
|
toast.fire({ icon: 'info', timer: 10000, text: exportModeDescription });
|
||||||
}, [exportModeDescription]);
|
}, [exportModeDescription]);
|
||||||
@ -171,7 +176,7 @@ function ExportConfirm({
|
|||||||
toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') });
|
toast.fire({ icon: 'info', timer: 10000, text: t('Enable experimental ffmpeg features flag?') });
|
||||||
}, [t]);
|
}, [t]);
|
||||||
|
|
||||||
const canEditTemplate = !willMerge || !autoDeleteMergedSegments;
|
const canEditSegTemplate = !willMerge || !autoDeleteMergedSegments;
|
||||||
|
|
||||||
const handleSmartCutBitrateToggle = useCallback((checked: boolean) => {
|
const handleSmartCutBitrateToggle = useCallback((checked: boolean) => {
|
||||||
setSmartCutBitrate(() => (checked ? undefined : 10000));
|
setSmartCutBitrate(() => (checked ? undefined : 10000));
|
||||||
@ -269,10 +274,10 @@ function ExportConfirm({
|
|||||||
<td />
|
<td />
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{canEditTemplate && (
|
{canEditSegTemplate && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={2}>
|
<td colSpan={2}>
|
||||||
<OutSegTemplateEditor outSegTemplate={outSegTemplate} setOutSegTemplate={setOutSegTemplate} generateOutSegFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
|
<FileNameTemplateEditor template={outSegTemplate} setTemplate={setOutSegTemplate} defaultTemplate={defaultOutSegTemplate} generateFileNames={generateOutSegFileNames} currentSegIndexSafe={currentSegIndexSafe} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<HelpIcon onClick={onOutSegTemplateHelpPress} />
|
<HelpIcon onClick={onOutSegTemplateHelpPress} />
|
||||||
@ -282,14 +287,11 @@ function ExportConfirm({
|
|||||||
|
|
||||||
{willMerge && (
|
{willMerge && (
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td colSpan={2}>
|
||||||
{t('Merged output file name:')}
|
<FileNameTemplateEditor template={mergedFileTemplate} setTemplate={setMergedFileTemplate} defaultTemplate={defaultMergedFileTemplate} generateFileNames={generateMergedFileNames} mergeMode />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<MergedOutFileName mergedOutFileName={mergedOutFileName} setMergedOutFileName={setMergedOutFileName} />
|
<HelpIcon onClick={onMergedFileTemplateHelpPress} />
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<HelpIcon onClick={() => showHelpText({ text: t('Name of the merged/concatenated output file when concatenating multiple segments.') })} />
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { memo, useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
import { memo, useState, useEffect, useCallback, useRef, useMemo, ChangeEventHandler } from 'react';
|
||||||
import { useDebounce } from 'use-debounce';
|
import { useDebounce } from 'use-debounce';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -9,7 +9,7 @@ import { FaEdit } from 'react-icons/fa';
|
|||||||
|
|
||||||
import { ReactSwal } from '../swal';
|
import { ReactSwal } from '../swal';
|
||||||
import HighlightedText from './HighlightedText';
|
import HighlightedText from './HighlightedText';
|
||||||
import { defaultOutSegTemplate, segNumVariable, segSuffixVariable, GenerateOutSegFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate';
|
import { segNumVariable, segSuffixVariable, GenerateOutFileNames, extVariable, segTagsVariable, segNumIntVariable } from '../util/outputNameTemplate';
|
||||||
import useUserSettings from '../hooks/useUserSettings';
|
import useUserSettings from '../hooks/useUserSettings';
|
||||||
import Switch from './Switch';
|
import Switch from './Switch';
|
||||||
import Select from './Select';
|
import Select from './Select';
|
||||||
@ -17,21 +17,32 @@ import TextInput from './TextInput';
|
|||||||
|
|
||||||
const electron = window.require('electron');
|
const electron = window.require('electron');
|
||||||
|
|
||||||
const formatVariable = (variable) => `\${${variable}}`;
|
|
||||||
|
const formatVariable = (variable: string) => `\${${variable}}`;
|
||||||
|
|
||||||
const extVariableFormatted = formatVariable(extVariable);
|
const extVariableFormatted = formatVariable(extVariable);
|
||||||
const segTagsExample = `${segTagsVariable}.XX`;
|
const segTagsExample = `${segTagsVariable}.XX`;
|
||||||
|
|
||||||
function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSegFileNames, currentSegIndexSafe }: {
|
function FileNameTemplateEditor(opts: {
|
||||||
outSegTemplate: string, setOutSegTemplate: (text: string) => void, generateOutSegFileNames: GenerateOutSegFileNames, currentSegIndexSafe: number,
|
template: string,
|
||||||
}) {
|
setTemplate: (text: string) => void,
|
||||||
|
defaultTemplate: string,
|
||||||
|
generateFileNames: GenerateOutFileNames,
|
||||||
|
} & ({
|
||||||
|
currentSegIndexSafe: number,
|
||||||
|
mergeMode?: false
|
||||||
|
} | {
|
||||||
|
mergeMode: true
|
||||||
|
})) {
|
||||||
|
const { template: templateIn, setTemplate, defaultTemplate, generateFileNames, mergeMode } = opts;
|
||||||
|
|
||||||
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
|
const { safeOutputFileName, toggleSafeOutputFileName, outputFileNameMinZeroPadding, setOutputFileNameMinZeroPadding } = useUserSettings();
|
||||||
|
|
||||||
const [text, setText] = useState(outSegTemplate);
|
const [text, setText] = useState(templateIn);
|
||||||
const [debouncedText] = useDebounce(text, 500);
|
const [debouncedText] = useDebounce(text, 500);
|
||||||
const [validText, setValidText] = useState<string>();
|
const [validText, setValidText] = useState<string>();
|
||||||
const [outSegProblems, setOutSegProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
|
const [problems, setProblems] = useState<{ error?: string | undefined, sameAsInputFileNameWarning?: boolean | undefined }>({ error: undefined, sameAsInputFileNameWarning: false });
|
||||||
const [outSegFileNames, setOutSegFileNames] = useState<string[]>();
|
const [fileNames, setFileNames] = useState<string[]>();
|
||||||
const [shown, setShown] = useState<boolean>();
|
const [shown, setShown] = useState<boolean>();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -48,59 +59,64 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// console.time('generateOutSegFileNames')
|
// console.time('generateFileNames')
|
||||||
const outSegs = await generateOutSegFileNames({ template: debouncedText });
|
const outSegs = await generateFileNames({ template: debouncedText });
|
||||||
// console.timeEnd('generateOutSegFileNames')
|
// console.timeEnd('generateOutSegFileNames')
|
||||||
if (abortController.signal.aborted) return;
|
if (abortController.signal.aborted) return;
|
||||||
setOutSegFileNames(outSegs.outSegFileNames);
|
setFileNames(outSegs.fileNames);
|
||||||
setOutSegProblems(outSegs.outSegProblems);
|
setProblems(outSegs.problems);
|
||||||
setValidText(outSegs.outSegProblems.error == null ? debouncedText : undefined);
|
setValidText(outSegs.problems.error == null ? debouncedText : undefined);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
setValidText(undefined);
|
setValidText(undefined);
|
||||||
setOutSegProblems({ error: err instanceof Error ? err.message : String(err) });
|
setProblems({ error: err instanceof Error ? err.message : String(err) });
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [debouncedText, generateOutSegFileNames, t]);
|
}, [debouncedText, generateFileNames, t]);
|
||||||
|
|
||||||
|
const availableVariables = useMemo(() => (mergeMode
|
||||||
|
? ['FILENAME', extVariable, 'EPOCH_MS']
|
||||||
|
: ['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS']
|
||||||
|
), [mergeMode]);
|
||||||
|
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted);
|
const isMissingExtension = validText != null && !validText.endsWith(extVariableFormatted);
|
||||||
|
|
||||||
const onAllSegmentsPreviewPress = useCallback(() => {
|
const onAllFilesPreviewPress = useCallback(() => {
|
||||||
if (outSegFileNames == null) return;
|
if (fileNames == null) return;
|
||||||
ReactSwal.fire({
|
ReactSwal.fire({
|
||||||
title: t('Resulting segment file names', { count: outSegFileNames.length }),
|
title: t('Resulting segment file names', { count: fileNames.length }),
|
||||||
html: (
|
html: (
|
||||||
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
<div style={{ textAlign: 'left', overflowY: 'auto', maxHeight: 400 }}>
|
||||||
{outSegFileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
{fileNames.map((f) => <div key={f} style={{ marginBottom: 7 }}>{f}</div>)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}, [outSegFileNames, t]);
|
}, [fileNames, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (validText != null) setOutSegTemplate(validText);
|
if (validText != null) setTemplate(validText);
|
||||||
}, [validText, setOutSegTemplate]);
|
}, [validText, setTemplate]);
|
||||||
|
|
||||||
const reset = useCallback(() => {
|
const reset = useCallback(() => {
|
||||||
setOutSegTemplate(defaultOutSegTemplate);
|
setTemplate(defaultTemplate);
|
||||||
setText(defaultOutSegTemplate);
|
setText(defaultTemplate);
|
||||||
}, [setOutSegTemplate]);
|
}, [defaultTemplate, setTemplate]);
|
||||||
|
|
||||||
const onHideClick = useCallback(() => {
|
const onHideClick = useCallback(() => {
|
||||||
if (outSegProblems.error == null) setShown(false);
|
if (problems.error == null) setShown(false);
|
||||||
}, [outSegProblems.error]);
|
}, [problems.error]);
|
||||||
|
|
||||||
const onShowClick = useCallback(() => {
|
const onShowClick = useCallback(() => {
|
||||||
if (!shown) setShown(true);
|
if (!shown) setShown(true);
|
||||||
}, [shown]);
|
}, [shown]);
|
||||||
|
|
||||||
const onTextChange = useCallback((e) => setText(e.target.value), []);
|
const onTextChange = useCallback<ChangeEventHandler<HTMLInputElement>>((e) => setText(e.target.value), []);
|
||||||
|
|
||||||
const gotImportantMessage = outSegProblems.error != null || outSegProblems.sameAsInputFileNameWarning;
|
const haveImportantMessage = problems.error != null || problems.sameAsInputFileNameWarning;
|
||||||
const needToShow = shown || gotImportantMessage;
|
const needToShow = shown || haveImportantMessage;
|
||||||
|
|
||||||
const onVariableClick = useCallback((variable: string) => {
|
const onVariableClick = useCallback((variable: string) => {
|
||||||
const input = inputRef.current;
|
const input = inputRef.current;
|
||||||
@ -115,12 +131,13 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
|||||||
}, [text]);
|
}, [text]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : 0 }}>
|
<motion.div style={{ maxWidth: 600 }} animate={{ margin: needToShow ? '1.5em 0' : '0 0 .3em 0' }}>
|
||||||
<div>{outSegFileNames != null && t('Output name(s):', { count: outSegFileNames.length })}</div>
|
<div>{fileNames != null && (mergeMode ? t('Merged output file name:') : t('Output name(s):', { count: fileNames.length }))}</div>
|
||||||
|
|
||||||
{outSegFileNames != null && (
|
{fileNames != null && (
|
||||||
<HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>
|
<HighlightedText role="button" onClick={onShowClick} style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', cursor: needToShow ? undefined : 'pointer' }}>
|
||||||
{outSegFileNames[currentSegIndexSafe] || outSegFileNames[0] || '-'}
|
{/* eslint-disable-next-line react/destructuring-assignment */}
|
||||||
|
{('currentSegIndexSafe' in opts ? fileNames[opts.currentSegIndexSafe] : undefined) || fileNames[0] || '-'}
|
||||||
{!needToShow && <FaEdit style={{ fontSize: '.9em', marginLeft: '.4em', verticalAlign: 'middle' }} />}
|
{!needToShow && <FaEdit style={{ fontSize: '.9em', marginLeft: '.4em', verticalAlign: 'middle' }} />}
|
||||||
</HighlightedText>
|
</HighlightedText>
|
||||||
)}
|
)}
|
||||||
@ -137,28 +154,28 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
|||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '.2em' }}>
|
||||||
<TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
|
<TextInput ref={inputRef} onChange={onTextChange} value={text} autoComplete="off" autoCapitalize="off" autoCorrect="off" />
|
||||||
|
|
||||||
{outSegFileNames != null && <Button height={20} onClick={onAllSegmentsPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
|
{!mergeMode && fileNames != null && <Button height={20} onClick={onAllFilesPreviewPress} marginLeft={5}>{t('Preview')}</Button>}
|
||||||
|
|
||||||
<IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" />
|
<IconButton title={t('Reset')} icon={ResetIcon} height={20} onClick={reset} marginLeft={5} intent="danger" />
|
||||||
{!gotImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />}
|
{!haveImportantMessage && <IconButton title={t('Close')} icon={TickIcon} height={20} onClick={onHideClick} marginLeft={5} intent="success" appearance="primary" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
<div style={{ fontSize: '.8em', color: 'var(--gray11)', display: 'flex', gap: '.3em', flexWrap: 'wrap', alignItems: 'center', marginBottom: '.7em' }}>
|
||||||
{`${i18n.t('Variables')}:`}
|
{`${i18n.t('Variables')}:`}
|
||||||
|
|
||||||
<IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} />
|
<IoIosHelpCircle fontSize="1.3em" color="var(--gray12)" role="button" cursor="pointer" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/blob/master/import-export.md#customising-exported-file-names')} />
|
||||||
{['FILENAME', 'CUT_FROM', 'CUT_TO', segNumVariable, segNumIntVariable, 'SEG_LABEL', segSuffixVariable, extVariable, segTagsExample, 'EPOCH_MS'].map((variable) => (
|
{availableVariables.map((variable) => (
|
||||||
<span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span>
|
<span key={variable} role="button" style={{ cursor: 'pointer', marginRight: '.2em', textDecoration: 'underline', textDecorationStyle: 'dashed', fontSize: '.9em' }} onClick={() => onVariableClick(variable)}>{variable}</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{outSegProblems.error != null && (
|
{problems.error != null && (
|
||||||
<div style={{ marginBottom: '1em' }}>
|
<div style={{ marginBottom: '1em' }}>
|
||||||
<ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {outSegProblems.error}
|
<ErrorIcon color="var(--red9)" size={14} verticalAlign="baseline" /> {problems.error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{outSegProblems.error == null && outSegProblems.sameAsInputFileNameWarning && (
|
{problems.error == null && problems.sameAsInputFileNameWarning && (
|
||||||
<div style={{ marginBottom: '1em' }}>
|
<div style={{ marginBottom: '1em' }}>
|
||||||
<WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '}
|
<WarningSignIcon verticalAlign="middle" color="var(--amber9)" />{' '}
|
||||||
{i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')}
|
{i18n.t('Output file name is the same as the source file name. This increases the risk of accidentally overwriting or deleting source files!')}
|
||||||
@ -177,7 +194,7 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
|||||||
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}>
|
<Select value={outputFileNameMinZeroPadding} onChange={(e) => setOutputFileNameMinZeroPadding(parseInt(e.target.value, 10))} style={{ marginRight: '1em', fontSize: '1em' }}>
|
||||||
{Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
{Array.from({ length: 10 }).map((_v, i) => i + 1).map((v) => <option key={v} value={v}>{v}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
Minimum numeric padded length
|
{t('Minimum numeric padded length')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -194,4 +211,4 @@ function OutSegTemplateEditor({ outSegTemplate, setOutSegTemplate, generateOutSe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(OutSegTemplateEditor);
|
export default memo(FileNameTemplateEditor);
|
@ -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;
|
if (!chapterNames) return undefined;
|
||||||
try {
|
try {
|
||||||
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });
|
const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 });
|
||||||
|
@ -82,7 +82,20 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', String(1 / outputPlaybackRate)] : []), [outputPlaybackRate]);
|
const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', String(1 / outputPlaybackRate)] : []), [outputPlaybackRate]);
|
||||||
|
|
||||||
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => undefined, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase }: {
|
const concatFiles = useCallback(async ({ paths, outDir, outPath, metadataFromPath, includeAllStreams, streams, outFormat, ffmpegExperimental, onProgress = () => undefined, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge, videoTimebase }: {
|
||||||
paths: string[], outDir: string | undefined, outPath: string, metadataFromPath: string, includeAllStreams: boolean, streams: FFprobeStream[], outFormat: string, ffmpegExperimental: boolean, onProgress?: (a: number) => void, preserveMovData: boolean, movFastStart: boolean, chapters: Chapter[] | undefined, preserveMetadataOnMerge: boolean, videoTimebase?: number | undefined,
|
paths: string[],
|
||||||
|
outDir: string | undefined,
|
||||||
|
outPath: string,
|
||||||
|
metadataFromPath: string,
|
||||||
|
includeAllStreams: boolean,
|
||||||
|
streams: FFprobeStream[],
|
||||||
|
outFormat?: string | undefined,
|
||||||
|
ffmpegExperimental: boolean,
|
||||||
|
onProgress?: (a: number) => void,
|
||||||
|
preserveMovData: boolean,
|
||||||
|
movFastStart: boolean,
|
||||||
|
chapters: Chapter[] | undefined,
|
||||||
|
preserveMetadataOnMerge: boolean,
|
||||||
|
videoTimebase?: number | undefined,
|
||||||
}) => {
|
}) => {
|
||||||
if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false };
|
if (await shouldSkipExistingFile(outPath)) return { haveExcludedStreams: false };
|
||||||
|
|
||||||
@ -500,7 +513,19 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
}
|
}
|
||||||
}, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, smartCutCustomBitrate, appendFfmpegCommandLog, concatFiles]);
|
}, [needSmartCut, filePath, losslessCutSingle, shouldSkipExistingFile, smartCutCustomBitrate, appendFfmpegCommandLog, concatFiles]);
|
||||||
|
|
||||||
const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }) => {
|
const autoConcatCutSegments = useCallback(async ({ customOutDir, outFormat, segmentPaths, ffmpegExperimental, onProgress, preserveMovData, movFastStart, autoDeleteMergedSegments, chapterNames, preserveMetadataOnMerge, mergedOutFilePath }: {
|
||||||
|
customOutDir: string | undefined,
|
||||||
|
outFormat: string | undefined,
|
||||||
|
segmentPaths: string[],
|
||||||
|
ffmpegExperimental: boolean,
|
||||||
|
onProgress: (p: number) => void,
|
||||||
|
preserveMovData: boolean,
|
||||||
|
movFastStart: boolean,
|
||||||
|
autoDeleteMergedSegments: boolean,
|
||||||
|
chapterNames: (string | undefined)[] | undefined,
|
||||||
|
preserveMetadataOnMerge: boolean,
|
||||||
|
mergedOutFilePath: string,
|
||||||
|
}) => {
|
||||||
const outDir = getOutDir(customOutDir, filePath);
|
const outDir = getOutDir(customOutDir, filePath);
|
||||||
|
|
||||||
if (await shouldSkipExistingFile(mergedOutFilePath)) return;
|
if (await shouldSkipExistingFile(mergedOutFilePath)) return;
|
||||||
@ -508,6 +533,7 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea
|
|||||||
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
|
||||||
|
|
||||||
const metadataFromPath = segmentPaths[0];
|
const metadataFromPath = segmentPaths[0];
|
||||||
|
invariant(metadataFromPath != null);
|
||||||
// need to re-read streams because may have changed
|
// need to re-read streams because may have changed
|
||||||
const { streams } = await readFileMeta(metadataFromPath);
|
const { streams } = await readFileMeta(metadataFromPath);
|
||||||
await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
await concatFiles({ paths: segmentPaths, outDir, outPath: mergedOutFilePath, metadataFromPath, outFormat, includeAllStreams: true, streams, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
|
||||||
|
@ -97,6 +97,8 @@ export default () => {
|
|||||||
useEffect(() => safeSetConfig({ simpleMode }), [simpleMode]);
|
useEffect(() => safeSetConfig({ simpleMode }), [simpleMode]);
|
||||||
const [outSegTemplate, setOutSegTemplate] = useState(safeGetConfigInitial('outSegTemplate'));
|
const [outSegTemplate, setOutSegTemplate] = useState(safeGetConfigInitial('outSegTemplate'));
|
||||||
useEffect(() => safeSetConfig({ outSegTemplate }), [outSegTemplate]);
|
useEffect(() => safeSetConfig({ outSegTemplate }), [outSegTemplate]);
|
||||||
|
const [mergedFileTemplate, setMergedFileTemplate] = useState(safeGetConfigInitial('mergedFileTemplate'));
|
||||||
|
useEffect(() => safeSetConfig({ mergedFileTemplate }), [mergedFileTemplate]);
|
||||||
const [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor'));
|
const [keyboardSeekAccFactor, setKeyboardSeekAccFactor] = useState(safeGetConfigInitial('keyboardSeekAccFactor'));
|
||||||
useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]);
|
useEffect(() => safeSetConfig({ keyboardSeekAccFactor }), [keyboardSeekAccFactor]);
|
||||||
const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed'));
|
const [keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed] = useState(safeGetConfigInitial('keyboardNormalSeekSpeed'));
|
||||||
@ -224,6 +226,8 @@ export default () => {
|
|||||||
setSimpleMode,
|
setSimpleMode,
|
||||||
outSegTemplate,
|
outSegTemplate,
|
||||||
setOutSegTemplate,
|
setOutSegTemplate,
|
||||||
|
mergedFileTemplate,
|
||||||
|
setMergedFileTemplate,
|
||||||
keyboardSeekAccFactor,
|
keyboardSeekAccFactor,
|
||||||
setKeyboardSeekAccFactor,
|
setKeyboardSeekAccFactor,
|
||||||
keyboardNormalSeekSpeed,
|
keyboardNormalSeekSpeed,
|
||||||
|
@ -18,8 +18,11 @@ export const segTagsVariable = 'SEG_TAGS';
|
|||||||
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path');
|
const { parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, basename }: PlatformPath = window.require('path');
|
||||||
|
|
||||||
|
|
||||||
function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName }: {
|
function getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName }: {
|
||||||
fileNames: string[], filePath: string, outputDir: string, safeOutputFileName: boolean
|
fileNames: string[],
|
||||||
|
filePath: string,
|
||||||
|
outputDir: string,
|
||||||
|
safeOutputFileName: boolean,
|
||||||
}) {
|
}) {
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
let sameAsInputFileNameWarning = false;
|
let sameAsInputFileNameWarning = false;
|
||||||
@ -30,7 +33,7 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const invalidChars = new Set();
|
const invalidChars = new Set<string>();
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
|
// https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names
|
||||||
// note that we allow path separators in some cases (see below)
|
// note that we allow path separators in some cases (see below)
|
||||||
@ -87,7 +90,7 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error == null && hasDuplicates(fileNames)) {
|
if (error == null && fileNames.length > 1 && hasDuplicates(fileNames)) {
|
||||||
error = i18n.t('Output file name template results in duplicate file names (you are trying to export multiple files with the same name). You can fix this for example by adding the "{{segNumVariable}}" variable.', { segNumVariable });
|
error = i18n.t('Output file name template results in duplicate file names (you are trying to export multiple files with the same name). You can fix this for example by adding the "{{segNumVariable}}" variable.', { segNumVariable });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,10 +103,22 @@ function getOutSegProblems({ fileNames, filePath, outputDir, safeOutputFileName
|
|||||||
// This is used as a fallback and so it has to always generate unique file names
|
// This is used as a fallback and so it has to always generate unique file names
|
||||||
// eslint-disable-next-line no-template-curly-in-string
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
|
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
export const defaultMergedFileTemplate = '${FILENAME}-cut-merged-${EPOCH_MS}${EXT}';
|
||||||
|
|
||||||
async function interpolateSegmentFileName({ template, epochMs, inputFileNameWithoutExt, segSuffix, ext, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: {
|
async function interpolateOutFileName(template: string, { epochMs, inputFileNameWithoutExt, ext, segSuffix, segNum, segNumPadded, segLabel, cutFrom, cutTo, tags }: {
|
||||||
template: string, epochMs: number, inputFileNameWithoutExt: string, segSuffix: string, ext: string, segNum: number, segNumPadded: string, segLabel: string, cutFrom: string, cutTo: string, tags: Record<string, string>
|
epochMs: number,
|
||||||
}) {
|
inputFileNameWithoutExt: string,
|
||||||
|
ext: string,
|
||||||
|
} & Partial<{
|
||||||
|
segSuffix: string,
|
||||||
|
segNum: number,
|
||||||
|
segNumPadded: string,
|
||||||
|
segLabel: string,
|
||||||
|
cutFrom: string,
|
||||||
|
cutTo: string,
|
||||||
|
tags: Record<string, string>,
|
||||||
|
}>) {
|
||||||
const context = {
|
const context = {
|
||||||
FILENAME: inputFileNameWithoutExt,
|
FILENAME: inputFileNameWithoutExt,
|
||||||
[segSuffixVariable]: segSuffix,
|
[segSuffixVariable]: segSuffix,
|
||||||
@ -114,7 +129,7 @@ async function interpolateSegmentFileName({ template, epochMs, inputFileNameWith
|
|||||||
EPOCH_MS: epochMs,
|
EPOCH_MS: epochMs,
|
||||||
CUT_FROM: cutFrom,
|
CUT_FROM: cutFrom,
|
||||||
CUT_TO: cutTo,
|
CUT_TO: cutTo,
|
||||||
[segTagsVariable]: {
|
[segTagsVariable]: tags && {
|
||||||
// allow both original case and uppercase
|
// allow both original case and uppercase
|
||||||
...tags,
|
...tags,
|
||||||
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
|
...Object.fromEntries(Object.entries(tags).map(([key, value]) => [`${key.toLocaleUpperCase('en-US')}`, value])),
|
||||||
@ -126,10 +141,33 @@ async function interpolateSegmentFileName({ template, epochMs, inputFileNameWith
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function maybeTruncatePath(fileName: string, truncate: boolean) {
|
||||||
|
// Split the path by its separator, so we can check the actual file name (last path seg)
|
||||||
|
const pathSegs = fileName.split(pathSep);
|
||||||
|
if (pathSegs.length === 0) return '';
|
||||||
|
const [lastSeg] = pathSegs.slice(-1);
|
||||||
|
const rest = pathSegs.slice(0, -1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...rest,
|
||||||
|
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
|
||||||
|
truncate ? lastSeg!.slice(0, 200) : lastSeg,
|
||||||
|
].join(pathSep);
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: {
|
export async function generateOutSegFileNames({ segments, template: desiredTemplate, formatTimecode, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName, maxLabelLength, outputFileNameMinZeroPadding }: {
|
||||||
segments: SegmentToExport[], template: string, formatTimecode: FormatTimecode, isCustomFormatSelected: boolean, fileFormat: string, filePath: string, outputDir: string, safeOutputFileName: boolean, maxLabelLength: number, outputFileNameMinZeroPadding: number,
|
segments: SegmentToExport[],
|
||||||
|
template: string,
|
||||||
|
formatTimecode: FormatTimecode,
|
||||||
|
isCustomFormatSelected: boolean,
|
||||||
|
fileFormat: string,
|
||||||
|
filePath: string,
|
||||||
|
outputDir: string,
|
||||||
|
safeOutputFileName: boolean,
|
||||||
|
maxLabelLength: number,
|
||||||
|
outputFileNameMinZeroPadding: number,
|
||||||
}) {
|
}) {
|
||||||
function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) {
|
async function generate({ template, forceSafeOutputFileName }: { template: string, forceSafeOutputFileName: boolean }) {
|
||||||
const epochMs = Date.now();
|
const epochMs = Date.now();
|
||||||
|
|
||||||
return pMap(segments, async (segment, i) => {
|
return pMap(segments, async (segment, i) => {
|
||||||
@ -150,8 +188,7 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
|
|||||||
|
|
||||||
const { name: inputFileNameWithoutExt } = parsePath(filePath);
|
const { name: inputFileNameWithoutExt } = parsePath(filePath);
|
||||||
|
|
||||||
const segFileName = await interpolateSegmentFileName({
|
const segFileName = await interpolateOutFileName(template, {
|
||||||
template,
|
|
||||||
epochMs,
|
epochMs,
|
||||||
segNum,
|
segNum,
|
||||||
segNumPadded,
|
segNumPadded,
|
||||||
@ -164,28 +201,56 @@ export async function generateOutSegFileNames({ segments, template: desiredTempl
|
|||||||
tags: Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)])),
|
tags: Object.fromEntries(Object.entries(getSegmentTags(segment)).map(([tag, value]) => [tag, filenamifyOrNot(value)])),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Now split the path by its separator, so we can check the actual file name (last path seg)
|
return maybeTruncatePath(segFileName, safeOutputFileName);
|
||||||
const pathSegs = segFileName.split(pathSep);
|
|
||||||
if (pathSegs.length === 0) return '';
|
|
||||||
const [lastSeg] = pathSegs.slice(-1);
|
|
||||||
const rest = pathSegs.slice(0, -1);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...rest,
|
|
||||||
// If sanitation is enabled, make sure filename (last seg of the path) is not too long
|
|
||||||
safeOutputFileName ? lastSeg!.slice(0, 200) : lastSeg,
|
|
||||||
].join(pathSep);
|
|
||||||
}, { concurrency: 5 });
|
}, { concurrency: 5 });
|
||||||
}
|
}
|
||||||
|
|
||||||
let outSegFileNames = await generate({ template: desiredTemplate, forceSafeOutputFileName: false });
|
let fileNames = await generate({ template: desiredTemplate, forceSafeOutputFileName: false });
|
||||||
|
|
||||||
const outSegProblems = getOutSegProblems({ fileNames: outSegFileNames, filePath, outputDir, safeOutputFileName });
|
const problems = getTemplateProblems({ fileNames, filePath, outputDir, safeOutputFileName });
|
||||||
if (outSegProblems.error != null) {
|
if (problems.error != null) {
|
||||||
outSegFileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true });
|
fileNames = await generate({ template: defaultOutSegTemplate, forceSafeOutputFileName: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
return { outSegFileNames, outSegProblems };
|
return { fileNames, problems };
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GenerateOutSegFileNames = (a: { segments?: SegmentToExport[], template: string }) => ReturnType<typeof generateOutSegFileNames>;
|
export type GenerateOutFileNames = (a: { template: string }) => Promise<{
|
||||||
|
fileNames: string[],
|
||||||
|
problems: {
|
||||||
|
error: string | undefined;
|
||||||
|
sameAsInputFileNameWarning: boolean;
|
||||||
|
},
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export async function generateMergedFileNames({ template: desiredTemplate, isCustomFormatSelected, fileFormat, filePath, outputDir, safeOutputFileName }: {
|
||||||
|
template: string,
|
||||||
|
isCustomFormatSelected: boolean,
|
||||||
|
fileFormat: string,
|
||||||
|
filePath: string,
|
||||||
|
outputDir: string,
|
||||||
|
safeOutputFileName: boolean,
|
||||||
|
}) {
|
||||||
|
async function generate(template: string) {
|
||||||
|
const epochMs = Date.now();
|
||||||
|
|
||||||
|
const { name: inputFileNameWithoutExt } = parsePath(filePath);
|
||||||
|
|
||||||
|
const fileName = await interpolateOutFileName(template, {
|
||||||
|
epochMs,
|
||||||
|
inputFileNameWithoutExt,
|
||||||
|
ext: getOutFileExtension({ isCustomFormatSelected, outFormat: fileFormat, filePath }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return maybeTruncatePath(fileName, safeOutputFileName);
|
||||||
|
}
|
||||||
|
|
||||||
|
let fileName = await generate(desiredTemplate);
|
||||||
|
|
||||||
|
const problems = getTemplateProblems({ fileNames: [fileName], filePath, outputDir, safeOutputFileName });
|
||||||
|
if (problems.error != null) {
|
||||||
|
fileName = await generate(defaultMergedFileTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { fileNames: [fileName], problems };
|
||||||
|
}
|
||||||
|
@ -114,7 +114,7 @@ export const isMov = (format: string | undefined) => format != null && ['ismv',
|
|||||||
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
|
type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined;
|
||||||
|
|
||||||
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: {
|
function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined }: {
|
||||||
stream: FFprobeStream, outputIndex: number, outFormat: string, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined
|
stream: FFprobeStream, outputIndex: number, outFormat: string | undefined, manuallyCopyDisposition?: boolean | undefined, getVideoArgs?: GetVideoArgsFn | undefined
|
||||||
}) {
|
}) {
|
||||||
let args: string[] = [];
|
let args: string[] = [];
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
|
|||||||
// ffmpeg cannot encode pcm_bluray
|
// ffmpeg cannot encode pcm_bluray
|
||||||
if (stream.codec_name === 'pcm_bluray' && outFormat !== 'mpegts') {
|
if (stream.codec_name === 'pcm_bluray' && outFormat !== 'mpegts') {
|
||||||
addCodecArgs('pcm_s24le');
|
addCodecArgs('pcm_s24le');
|
||||||
} else if (stream.codec_name === 'pcm_dvd' && ['matroska', 'mov'].includes(outFormat)) {
|
} else if (stream.codec_name === 'pcm_dvd' && outFormat != null && ['matroska', 'mov'].includes(outFormat)) {
|
||||||
// https://github.com/mifi/lossless-cut/discussions/2092
|
// https://github.com/mifi/lossless-cut/discussions/2092
|
||||||
// coolitnow-partial.vob
|
// coolitnow-partial.vob
|
||||||
// https://superuser.com/questions/1272614/use-ffmpeg-to-merge-mpeg2-files-with-pcm-dvd-audio
|
// https://superuser.com/questions/1272614/use-ffmpeg-to-merge-mpeg2-files-with-pcm-dvd-audio
|
||||||
@ -204,7 +204,7 @@ function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposi
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }: {
|
export function getMapStreamsArgs({ startIndex = 0, outFormat, allFilesMeta, copyFileStreams, manuallyCopyDisposition, getVideoArgs }: {
|
||||||
startIndex?: number, outFormat: string, allFilesMeta, copyFileStreams: { streamIds: number[], path: string }[], manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn,
|
startIndex?: number, outFormat: string | undefined, allFilesMeta, copyFileStreams: { streamIds: number[], path: string }[], manuallyCopyDisposition?: boolean, getVideoArgs?: GetVideoArgsFn,
|
||||||
}) {
|
}) {
|
||||||
let args: string[] = [];
|
let args: string[] = [];
|
||||||
let outputIndex = startIndex;
|
let outputIndex = startIndex;
|
||||||
|
1
types.ts
1
types.ts
@ -72,6 +72,7 @@ export interface Config {
|
|||||||
preserveMetadataOnMerge: boolean,
|
preserveMetadataOnMerge: boolean,
|
||||||
simpleMode: boolean,
|
simpleMode: boolean,
|
||||||
outSegTemplate: string | undefined,
|
outSegTemplate: string | undefined,
|
||||||
|
mergedFileTemplate: string | undefined,
|
||||||
keyboardSeekAccFactor: number,
|
keyboardSeekAccFactor: number,
|
||||||
keyboardNormalSeekSpeed: number,
|
keyboardNormalSeekSpeed: number,
|
||||||
keyboardSeekSpeed2: number,
|
keyboardSeekSpeed2: number,
|
||||||
|
Loading…
Reference in New Issue
Block a user