diff --git a/public/configStore.js b/public/configStore.js
index 4c110964..648ea4bf 100644
--- a/public/configStore.js
+++ b/public/configStore.js
@@ -13,6 +13,7 @@ const defaults = {
keyframeCut: true,
autoMerge: false,
autoDeleteMergedSegments: true,
+ segmentsToChaptersOnly: false,
timecodeFormat: 'timecodeWithDecimalFraction',
invertCutSegments: false,
autoExportExtraStreams: true,
diff --git a/src/App.jsx b/src/App.jsx
index fd42117f..f8eda2ac 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -194,7 +194,7 @@ const App = memo(() => {
const isCustomFormatSelected = fileFormat !== detectedFileFormat;
const {
- captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify,
+ captureFormat, setCaptureFormat, customOutDir, setCustomOutDir, keyframeCut, setKeyframeCut, preserveMovData, setPreserveMovData, movFastStart, setMovFastStart, avoidNegativeTs, setAvoidNegativeTs, autoMerge, setAutoMerge, timecodeFormat, setTimecodeFormat, invertCutSegments, setInvertCutSegments, autoExportExtraStreams, setAutoExportExtraStreams, askBeforeClose, setAskBeforeClose, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction, playbackVolume, setPlaybackVolume, autoSaveProjectFile, setAutoSaveProjectFile, wheelSensitivity, setWheelSensitivity, invertTimelineScroll, setInvertTimelineScroll, language, setLanguage, ffmpegExperimental, setFfmpegExperimental, hideNotifications, setHideNotifications, autoLoadTimecode, setAutoLoadTimecode, autoDeleteMergedSegments, setAutoDeleteMergedSegments, exportConfirmEnabled, setExportConfirmEnabled, segmentsToChapters, setSegmentsToChapters, preserveMetadataOnMerge, setPreserveMetadataOnMerge, simpleMode, setSimpleMode, outSegTemplate, setOutSegTemplate, keyboardSeekAccFactor, setKeyboardSeekAccFactor, keyboardNormalSeekSpeed, setKeyboardNormalSeekSpeed, enableTransferTimestamps, setEnableTransferTimestamps, outFormatLocked, setOutFormatLocked, safeOutputFileName, setSafeOutputFileName, enableAutoHtml5ify, setEnableAutoHtml5ify, segmentsToChaptersOnly, setSegmentsToChaptersOnly,
} = useUserPreferences();
const {
@@ -1121,12 +1121,23 @@ const App = memo(() => {
try {
setWorking(i18n.t('Exporting'));
+ // This is a special mode where segments will be simply written out as chapters: https://github.com/mifi/lossless-cut/issues/993#issuecomment-1037927595
+ let chaptersToAdd;
+ let segmentsToExport = enabledOutSegments;
+ if (segmentsToChaptersOnly) {
+ if (invertCutSegments) throw new Error('Inverted cut segments not supported for chapters only export');
+ // Emulate a single segment with no cuts (full timeline)
+ segmentsToExport = [{ start: 0, end: getSegApparentEnd({}) }];
+ chaptersToAdd = sortBy(enabledOutSegments, 'start').map((segment) => ({ start: segment.start, end: segment.end, name: segment.name }));
+ console.log(chaptersToAdd);
+ }
+
console.log('outSegTemplateOrDefault', outSegTemplateOrDefault);
- let outSegFileNames = generateOutSegFileNames({ segments: enabledOutSegments, template: outSegTemplateOrDefault });
+ let outSegFileNames = generateOutSegFileNames({ segments: segmentsToExport, template: outSegTemplateOrDefault });
if (!isOutSegFileNamesValid(outSegFileNames) || hasDuplicates(outSegFileNames)) {
console.error('Output segments file name invalid, using default instead', outSegFileNames);
- outSegFileNames = generateOutSegFileNames({ segments: enabledOutSegments, template: defaultOutSegTemplate });
+ outSegFileNames = generateOutSegFileNames({ segments: segmentsToExport, template: defaultOutSegTemplate });
}
// throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })();
@@ -1137,7 +1148,7 @@ const App = memo(() => {
rotation: isRotationSet ? effectiveRotation : undefined,
copyFileStreams,
keyframeCut,
- segments: enabledOutSegments,
+ segments: segmentsToExport,
segmentsFileNames: outSegFileNames,
onProgress: setCutProgress,
appendFfmpegCommandLog,
@@ -1149,13 +1160,14 @@ const App = memo(() => {
customTagsByFile,
customTagsByStreamId,
dispositionByStreamId,
+ chapters: chaptersToAdd,
});
if (outFiles.length > 1 && autoMerge) {
setCutProgress(0);
setWorking(i18n.t('Merging'));
- const chapterNames = segmentsToChapters && !invertCutSegments ? enabledOutSegments.map((s) => s.name) : undefined;
+ const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined;
await autoMergeSegments({
customOutDir,
@@ -1205,7 +1217,7 @@ const App = memo(() => {
setWorking();
setCutProgress();
}
- }, [numStreamsToCopy, enabledOutSegments, outSegTemplateOrDefault, generateOutSegFileNames, customOutDir, filePath, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, autoMerge, exportExtraStreams, fileFormatData, mainStreams, hideAllNotifications, outputDir, segmentsToChapters, invertCutSegments, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, nonCopiedExtraStreams, handleCutFailed, isOutSegFileNamesValid, cutMultiple, autoMergeSegments, setWorking]);
+ }, [numStreamsToCopy, setWorking, enabledOutSegments, segmentsToChaptersOnly, outSegTemplateOrDefault, generateOutSegFileNames, isOutSegFileNamesValid, cutMultiple, outputDir, fileFormat, duration, isRotationSet, effectiveRotation, copyFileStreams, keyframeCut, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs, customTagsByFile, customTagsByStreamId, dispositionByStreamId, autoMerge, fileFormatData, mainStreams, exportExtraStreams, hideAllNotifications, invertCutSegments, getSegApparentEnd, segmentsToChapters, autoMergeSegments, customOutDir, isCustomFormatSelected, autoDeleteMergedSegments, preserveMetadataOnMerge, filePath, nonCopiedExtraStreams, handleCutFailed]);
const onExportPress = useCallback(async () => {
if (!filePath || workingRef.current) return;
@@ -2251,6 +2263,8 @@ const App = memo(() => {
outFormatLocked={outFormatLocked}
onOutFormatLockedClick={onOutFormatLockedClick}
simpleMode={simpleMode}
+ segmentsToChaptersOnly={segmentsToChaptersOnly}
+ setSegmentsToChaptersOnly={setSegmentsToChaptersOnly}
/>
@@ -2522,7 +2536,7 @@ const App = memo(() => {
/>
-
+
{
const { t } = useTranslation();
@@ -97,6 +97,8 @@ const ExportConfirm = memo(({
const outSegTemplateHelpIcon = ;
+ const willMerge = autoMerge && enabledOutSegments.length >= 2;
+
// https://stackoverflow.com/questions/33454533/cant-scroll-to-top-of-flex-item-that-is-overflowing-container
return (
@@ -115,7 +117,7 @@ const ExportConfirm = memo(({
{t('Export options')}
- {enabledOutSegments.length >= 2 && - {t('Merge {{segments}} cut segments to one file?', { segments: enabledOutSegments.length })}
}
+ {enabledOutSegments.length >= 2 && - {t('Merge {{segments}} cut segments to one file?', { segments: enabledOutSegments.length })}
}
-
{t('Output container format:')} {renderOutFmt({ height: 20, maxWidth: 150 })}
@@ -127,7 +129,7 @@ const ExportConfirm = memo(({
-
{t('Save output to path:')} {outputDir}
- {(enabledOutSegments.length === 1 || !autoMerge) && (
+ {!willMerge && !segmentsToChaptersOnly && (
-
@@ -136,7 +138,7 @@ const ExportConfirm = memo(({
{t('Advanced options')}
- {autoMerge && enabledOutSegments.length >= 2 && (
+ {willMerge && !segmentsToChaptersOnly && (
-
{t('Create chapters from merged segments? (slow)')}
diff --git a/src/TopMenu.jsx b/src/TopMenu.jsx
index bcd0973b..08e5dc04 100644
--- a/src/TopMenu.jsx
+++ b/src/TopMenu.jsx
@@ -14,6 +14,7 @@ const TopMenu = memo(({
filePath, copyAnyAudioTrack, toggleStripAudio, customOutDir, changeOutDir,
renderOutFmt, toggleHelp, numStreamsToCopy, numStreamsTotal, setStreamsSelectorShown, toggleSettings,
enabledOutSegments, autoMerge, setAutoMerge, autoDeleteMergedSegments, setAutoDeleteMergedSegments, isCustomFormatSelected, onOutFormatLockedClick, simpleMode, outFormatLocked, clearOutDir,
+ segmentsToChaptersOnly, setSegmentsToChaptersOnly,
}) => {
const { t } = useTranslation();
@@ -74,7 +75,7 @@ const TopMenu = memo(({
{!simpleMode && (isCustomFormatSelected || outFormatLocked) && renderFormatLock()}
-
+
>
)}
diff --git a/src/components/MergeExportButton.jsx b/src/components/MergeExportButton.jsx
index 5f4557b7..fac2a253 100644
--- a/src/components/MergeExportButton.jsx
+++ b/src/components/MergeExportButton.jsx
@@ -6,7 +6,7 @@ import { MdCallSplit, MdCallMerge } from 'react-icons/md';
import { withBlur } from '../util';
-const MergeExportButton = memo(({ autoMerge, enabledOutSegments, setAutoMerge, autoDeleteMergedSegments, setAutoDeleteMergedSegments }) => {
+const MergeExportButton = memo(({ autoMerge, enabledOutSegments, setAutoMerge, autoDeleteMergedSegments, setAutoDeleteMergedSegments, segmentsToChaptersOnly, setSegmentsToChaptersOnly }) => {
const { t } = useTranslation();
let AutoMergeIcon;
@@ -14,7 +14,12 @@ const MergeExportButton = memo(({ autoMerge, enabledOutSegments, setAutoMerge, a
let effectiveMode;
let title;
let description;
- if (autoMerge && autoDeleteMergedSegments) {
+
+ if (segmentsToChaptersOnly) {
+ effectiveMode = 'sesgments_to_chapters';
+ title = t('Chapters only');
+ description = t('Don\'t cut the file, but instead create chapters from segments');
+ } else if (autoMerge && autoDeleteMergedSegments) {
effectiveMode = 'merge';
AutoMergeIcon = MdCallMerge;
title = t('Merge cuts');
@@ -41,8 +46,13 @@ const MergeExportButton = memo(({ autoMerge, enabledOutSegments, setAutoMerge, a
break;
}
case 'separate': {
+ setSegmentsToChaptersOnly(true);
+ break;
+ }
+ case 'sesgments_to_chapters': {
setAutoMerge(true);
setAutoDeleteMergedSegments(true);
+ setSegmentsToChaptersOnly(false);
break;
}
default:
diff --git a/src/hooks/useFfmpegOperations.js b/src/hooks/useFfmpegOperations.js
index 58f41dbc..494e6fe9 100644
--- a/src/hooks/useFfmpegOperations.js
+++ b/src/hooks/useFfmpegOperations.js
@@ -50,6 +50,8 @@ function getMatroskaFlags() {
];
}
+const getChaptersInputArgs = (ffmetadataPath) => (ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []);
+
function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const optionalTransferTimestamps = useCallback(async (...args) => {
if (enableTransferTimestamps) await transferTimestamps(...args);
@@ -61,8 +63,11 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
outputDir, segments, segmentsFileNames, videoDuration, rotation,
onProgress: onTotalProgress, keyframeCut, copyFileStreams, outFormat,
appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, preserveMovData, movFastStart, avoidNegativeTs,
- customTagsByFile, customTagsByStreamId, dispositionByStreamId,
+ customTagsByFile, customTagsByStreamId, dispositionByStreamId, chapters,
}) => {
+ // We can optionally write chapters
+ const chaptersPath = chapters ? await writeChaptersFfmetadata(outputDir, chapters) : undefined;
+
async function cutSingle({ cutFrom, cutTo, onProgress, outPath }) {
const cuttingStart = isCuttingStart(cutFrom);
const cuttingEnd = isCuttingEnd(cutTo, videoDuration);
@@ -81,18 +86,25 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
// remove -avoid_negative_ts make_zero when not cutting start (no -ss), or else some videos get blank first frame in QuickLook
const avoidNegativeTsArgs = cuttingStart && avoidNegativeTs ? ['-avoid_negative_ts', avoidNegativeTs] : [];
- const inputArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
- const inputCutArgs = ssBeforeInput ? [
+ const inputFilesArgs = flatMap(copyFileStreamsFiltered, ({ path }) => ['-i', path]);
+ const inputFilesArgsWithCuts = ssBeforeInput ? [
...cutFromArgs,
- ...inputArgs,
+ ...inputFilesArgs,
...cutToArgs,
...avoidNegativeTsArgs,
] : [
- ...inputArgs,
+ ...inputFilesArgs,
...cutFromArgs,
...cutToArgs,
];
+ const inputArgs = [
+ ...inputFilesArgsWithCuts,
+ ...getChaptersInputArgs(chaptersPath),
+ ];
+
+ const chaptersInputIndex = copyFileStreamsFiltered.length;
+
const rotationArgs = rotation !== undefined ? ['-metadata:s:v:0', `rotate=${360 - rotation}`] : [];
// This function tries to calculate the output stream index needed for -metadata:s:x and -disposition:x arguments
@@ -152,15 +164,18 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
- ...inputCutArgs,
+ ...inputArgs,
'-c', 'copy',
...(shortestFlag ? ['-shortest'] : []),
...flatMapDeep(copyFileStreamsFiltered, ({ streamIds }, fileIndex) => streamIds.map(streamId => ['-map', `${fileIndex}:${streamId}`])),
+
'-map_metadata', '0',
+ ...(chaptersPath ? ['-map_chapters', chaptersInputIndex] : []),
+
...getMovFlags({ preserveMovData, movFastStart }),
...getMatroskaFlags(),
@@ -225,9 +240,40 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const durations = await pMap(paths, getDuration, { concurrency: 1 });
const totalDuration = sum(durations);
- const ffmetadataPath = await writeChaptersFfmetadata(outDir, chapters);
+ const chaptersPath = await writeChaptersFfmetadata(outDir, chapters);
try {
+ let inputArgs = [];
+ let inputIndex = 0;
+
+ // Keep track of input index to be used later
+ // eslint-disable-next-line no-inner-declarations
+ function addInput(args) {
+ inputArgs = [...inputArgs, ...args];
+ const retIndex = inputIndex;
+ inputIndex += 1;
+ return retIndex;
+ }
+
+ // concat list - always first
+ addInput([
+ // https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
+ '-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe',
+ '-i', '-',
+ ]);
+
+ let metadataSourceIndex;
+ if (preserveMetadataOnMerge) {
+ // If preserve metadata, add the first file (we will get metadata from this input)
+ metadataSourceIndex = addInput(['-i', paths[0]]);
+ }
+
+ let chaptersInputIndex;
+ if (chaptersPath) {
+ // if chapters, add chapters source file
+ chaptersInputIndex = addInput(getChaptersInputArgs(chaptersPath));
+ }
+
let map;
if (allStreams) map = ['-map', '0'];
// If preserveMetadataOnMerge option is enabled, we need to explicitly map even if allStreams=false.
@@ -243,15 +289,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
// No progress if we set loglevel warning :(
// '-loglevel', 'warning',
- // https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
- '-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe',
- '-i', '-',
-
- // Add the first file (we will get metadata from this input)
- ...(preserveMetadataOnMerge ? ['-i', paths[0]] : []),
-
- // Chapters?
- ...(ffmetadataPath ? ['-f', 'ffmetadata', '-i', ffmetadataPath] : []),
+ ...inputArgs,
'-c', 'copy',
@@ -260,7 +298,9 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
// -map_metadata 0 with concat demuxer doesn't transfer metadata from the concat'ed file input (index 0) when merging.
// So we use the first file file (index 1) for metadata
// Can only do this if allStreams (-map 0) is set
- ...(preserveMetadataOnMerge ? ['-map_metadata', '1'] : []),
+ ...(metadataSourceIndex != null ? ['-map_metadata', metadataSourceIndex] : []),
+
+ ...(chaptersInputIndex != null ? ['-map_chapters', chaptersInputIndex] : []),
...getMovFlags({ preserveMovData, movFastStart }),
...getMatroskaFlags(),
@@ -294,7 +334,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const { stdout } = await process;
console.log(stdout);
} finally {
- if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
+ if (chaptersPath) await fs.unlink(chaptersPath).catch((err) => console.error('Failed to delete', chaptersPath, err));
}
await optionalTransferTimestamps(paths[0], outPath);
diff --git a/src/hooks/useUserPreferences.js b/src/hooks/useUserPreferences.js
index 3fd3a52e..0641afbb 100644
--- a/src/hooks/useUserPreferences.js
+++ b/src/hooks/useUserPreferences.js
@@ -90,7 +90,8 @@ export default () => {
useEffect(() => safeSetConfig('safeOutputFileName', safeOutputFileName), [safeOutputFileName]);
const [enableAutoHtml5ify, setEnableAutoHtml5ify] = useState(configStore.get('enableAutoHtml5ify'));
useEffect(() => safeSetConfig('enableAutoHtml5ify', enableAutoHtml5ify), [enableAutoHtml5ify]);
-
+ const [segmentsToChaptersOnly, setSegmentsToChaptersOnly] = useState(configStore.get('segmentsToChaptersOnly'));
+ useEffect(() => safeSetConfig('segmentsToChaptersOnly', segmentsToChaptersOnly), [segmentsToChaptersOnly]);
// NOTE! This useEffect must be placed after all usages of firstUpdateRef.current (safeSetConfig)
useEffect(() => {
@@ -167,5 +168,7 @@ export default () => {
setSafeOutputFileName,
enableAutoHtml5ify,
setEnableAutoHtml5ify,
+ segmentsToChaptersOnly,
+ setSegmentsToChaptersOnly,
};
};