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, }; };