From 95e6a5d198760397689ad2f9485eb2830c203ca9 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Fri, 30 Dec 2022 16:15:22 +0800 Subject: [PATCH] implement combine overlapping segments https://github.com/mifi/lossless-cut/issues/1334#issuecomment-1367786230 --- public/menu.js | 6 ++++ src/App.jsx | 30 ++++++++++---------- src/SegmentList.jsx | 12 ++++++-- src/ffmpeg.js | 3 +- src/segments.js | 65 ++++++++++++++++++++++++++++++++++++++++---- src/segments.test.js | 60 +++++++++++++++++++++++++++++++++++++++- src/util.js | 2 -- 7 files changed, 152 insertions(+), 26 deletions(-) diff --git a/public/menu.js b/public/menu.js index d5bb8aaf..37ef2f42 100644 --- a/public/menu.js +++ b/public/menu.js @@ -228,6 +228,12 @@ module.exports = (app, mainWindow, newVersion) => { mainWindow.webContents.send('fillSegmentsGaps'); }, }, + { + label: i18n.t('Combine overlapping segments'), + click() { + mainWindow.webContents.send('combineOverlappingSegments'); + }, + }, { label: i18n.t('Shuffle segments order'), click() { diff --git a/src/App.jsx b/src/App.jsx index 98f7934e..d3b1d491 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -63,7 +63,7 @@ import { formatYouTube, getFrameCountRaw } from './edlFormats'; import { getOutPath, getSuffixedOutPath, toast, errorToast, handleError, setFileNameTitle, getOutDir, getFileDir, checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, - isDurationValid, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, + filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, havePermissionToReadFile, resolvePathIfNeeded, getPathReadAccessError, html5ifiedPrefix, html5dummySuffix, findExistingHtml5FriendlyFile, deleteFiles, isOutOfSpaceError, shuffleArray, } from './util'; @@ -72,7 +72,7 @@ import { adjustRate } from './util/rate-calculator'; import { askForOutDir, askForInputDir, askForImportChapters, createNumSegments as createNumSegmentsDialog, createFixedDurationSegments as createFixedDurationSegmentsDialog, createRandomSegments as createRandomSegmentsDialog, promptTimeOffset, askForHtml5ifySpeed, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog, openAbout, showEditableJsonDialog, askForShiftSegments, selectSegmentsByLabelDialog, confirmExtractFramesAsImages, showRefuseToOverwrite, showParametersDialog } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { createSegment, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap } from './segments'; +import { createSegment, getCleanCutSegments, getSegApparentStart, getSegApparentEnd as getSegApparentEnd2, findSegmentsAtCursor, sortSegments, invertSegments, getSegmentTags, convertSegmentsToChapters, hasAnySegmentOverlap, combineOverlappingSegments as combineOverlappingSegments2, isDurationValid } from './segments'; import { getOutSegError as getOutSegErrorRaw } from './util/outputNameTemplate'; import * as ffmpegParameters from './ffmpeg-parameters'; import { maxSegmentsAllowed, ffmpegExtractWindow, zoomMax, rightBarWidth, leftBarWidth } from './util/constants'; @@ -95,7 +95,6 @@ const calcShouldShowWaveform = (zoomedDuration) => (zoomedDuration != null && zo const calcShouldShowKeyframes = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8); - const videoStyle = { width: '100%', height: '100%', objectFit: 'contain' }; const bottomMotionStyle = { background: controlsBackground }; @@ -344,19 +343,16 @@ const App = memo(() => { const onTimelineWheel = useTimelineScroll({ wheelSensitivity, mouseWheelZoomModifierKey, invertTimelineScroll, zoomRel, seekRel }); - const getSegApparentEnd = useCallback((seg) => { - const time = seg.end; - if (time !== undefined) return time; - if (isDurationValid(duration)) return duration; - return 0; // Haven't gotten duration yet - }, [duration]); + const getSegApparentEnd = useCallback((seg) => getSegApparentEnd2(seg, duration), [duration]); - // These are segments guaranteed to have a start and end time - const apparentCutSegments = useMemo(() => cutSegments.map(cutSegment => ({ + const getApparentCutSegments = useCallback((segments) => segments.map((cutSegment) => ({ ...cutSegment, start: getSegApparentStart(cutSegment), end: getSegApparentEnd(cutSegment), - })), [cutSegments, getSegApparentEnd]); + })), [getSegApparentEnd]); + + // These are segments guaranteed to have a start and end time + const apparentCutSegments = useMemo(() => getApparentCutSegments(cutSegments), [cutSegments, getApparentCutSegments]); const haveInvalidSegs = useMemo(() => apparentCutSegments.some((cutSegment) => cutSegment.start >= cutSegment.end), [apparentCutSegments]); @@ -395,6 +391,10 @@ const App = memo(() => { setCutSegments((existing) => ([...existing, ...newInverseCutSegments])); }, [createIndexedSegment, inverseCutSegments, setCutSegments]); + const combineOverlappingSegments = useCallback(() => { + setCutSegments((existingSegments) => combineOverlappingSegments2(existingSegments, getSegApparentEnd)); + }, [getSegApparentEnd, setCutSegments]); + const updateSegAtIndex = useCallback((index, newProps) => { if (index < 0) return; const cutSegmentsNew = [...cutSegments]; @@ -2094,6 +2094,7 @@ const App = memo(() => { reorderSegsByStartTime, invertAllSegments, fillSegmentsGaps, + combineOverlappingSegments, createFixedDurationSegments, createNumSegments, createRandomSegments, @@ -2158,7 +2159,7 @@ const App = memo(() => { if (match) return bubble; return true; // bubble the event - }, [addSegment, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, closeExportConfirm, concatCurrentBatch, concatDialogVisible, convertFormatBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, exportConfirmVisible, extractAllStreams, extractCurrentSegmentFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, keyboardShortcutsVisible, onExportConfirm, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyboardShortcuts, toggleKeyframeCut, toggleLastCommands, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]); + }, [addSegment, askSetStartTimeOffset, batchFileJump, batchOpenSelectedFile, captureSnapshot, captureSnapshotAsCoverArt, changePlaybackRate, cleanupFilesDialog, clearSegments, closeBatch, closeExportConfirm, combineOverlappingSegments, concatCurrentBatch, concatDialogVisible, convertFormatBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, currentSegIndexSafe, cutSegmentsHistory, deselectAllSegments, exportConfirmVisible, extractAllStreams, extractCurrentSegmentFramesAsImages, fillSegmentsGaps, goToTimecode, increaseRotation, invertAllSegments, jumpCutEnd, jumpCutStart, jumpSeg, jumpTimelineEnd, jumpTimelineStart, keyboardNormalSeekSpeed, keyboardSeekAccFactor, keyboardShortcutsVisible, onExportConfirm, onExportPress, onLabelSegment, pause, play, removeCutSegment, removeSelectedSegments, reorderSegsByStartTime, seekClosestKeyframe, seekRel, seekRelPercent, selectAllSegments, selectOnlyCurrentSegment, setCutEnd, setCutStart, setPlaybackVolume, shortStep, shuffleSegments, splitCurrentSegment, timelineToggleComfortZoom, toggleCaptureFormat, toggleCurrentSegmentSelected, toggleKeyboardShortcuts, toggleKeyframeCut, toggleLastCommands, togglePlay, toggleSegmentsList, toggleStreamsSelector, toggleStripAudio, tryFixInvalidDuration, userHtml5ifyCurrentFile, zoomRel]); useKeyboard({ keyBindings, onKeyPress }); @@ -2286,6 +2287,7 @@ const App = memo(() => { createRandomSegments, invertAllSegments, fillSegmentsGaps, + combineOverlappingSegments, fixInvalidDuration: tryFixInvalidDuration, reorderSegsByStartTime, concatCurrentBatch, @@ -2309,7 +2311,7 @@ const App = memo(() => { actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.on(key, action)); return () => actionsWithCatch.forEach(([key, action]) => electron.ipcRenderer.removeListener(key, action)); - }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, concatCurrentBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, createSegmentsFromKeyframes, customOutDir, cutSegments, detectBlackScenes, detectSceneChanges, detectSilentScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); + }, [apparentCutSegments, askSetStartTimeOffset, checkFileOpened, clearSegments, closeBatch, closeFileWithConfirm, combineOverlappingSegments, concatCurrentBatch, createFixedDurationSegments, createNumSegments, createRandomSegments, createSegmentsFromKeyframes, customOutDir, cutSegments, detectBlackScenes, detectSceneChanges, detectSilentScenes, detectedFps, extractAllStreams, fileFormat, filePath, fillSegmentsGaps, getFrameCount, invertAllSegments, loadCutSegments, loadMedia, openFilesDialog, openSendReportDialogWithState, reorderSegsByStartTime, setWorking, shiftAllSegmentTimes, shuffleSegments, toggleKeyboardShortcuts, toggleLastCommands, toggleSettings, tryFixInvalidDuration, userHtml5ifyCurrentFile, userOpenFiles]); const showAddStreamSourceDialog = useCallback(async () => { try { diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index fd59e422..2b8c7a27 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -161,8 +161,13 @@ const SegmentList = memo(({ let header = t('Segments to export:'); if (segments.length === 0) { - if (invertCutSegments) header = You have enabled the "invert segments" mode which will cut away selected segments instead of keeping them. But there is no space between any segments, or at least two segments are overlapping. This would not produce any output. Either make room between segments or click the Yinyang symbol below to disable this mode.; - else header = t('No segments to export.'); + if (invertCutSegments) { + header = ( + You have enabled the "invert segments" mode which will cut away selected segments instead of keeping them. But there is no space between any segments, or at least two segments are overlapping. This would not produce any output. Either make room between segments or click the Yinyang symbol below to disable this mode. Alternatively you may combine overlapping segments from the menu. + ); + } else { + header = t('No segments to export.'); + } } async function onReorderSegs(index) { @@ -254,7 +259,7 @@ const SegmentList = memo(({ animate={{ x: 0 }} exit={{ x: width }} > -
+
+
{sortableList.map(({ id, seg }, index) => { diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 29f8b672..a7bfc84d 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -5,7 +5,8 @@ import i18n from 'i18next'; import Timecode from 'smpte-timecode'; import { pcmAudioCodecs, getMapStreamsArgs } from './util/streams'; -import { getSuffixedOutPath, isDurationValid, getExtensionForFormat, isWindows, isMac, platform, arch } from './util'; +import { getSuffixedOutPath, getExtensionForFormat, isWindows, isMac, platform, arch } from './util'; +import { isDurationValid } from './segments'; import isDev from './isDev'; diff --git a/src/segments.js b/src/segments.js index 13d1e9b1..0b4601b4 100644 --- a/src/segments.js +++ b/src/segments.js @@ -1,6 +1,9 @@ import { v4 as uuidv4 } from 'uuid'; import sortBy from 'lodash/sortBy'; + +export const isDurationValid = (duration) => Number.isFinite(duration) && duration > 0; + export const createSegment = ({ start, end, name, tags, segColorIndex } = {}) => ({ start, end, @@ -22,6 +25,13 @@ export function getSegApparentStart(seg) { return time !== undefined ? time : 0; } +export function getSegApparentEnd(seg, duration) { + const time = seg.end; + if (time !== undefined) return time; + if (isDurationValid(duration)) return duration; + return 0; // Haven't gotten duration yet - what do to ¯\_(ツ)_/¯ +} + export const getCleanCutSegments = (cs) => cs.map((seg) => ({ start: seg.start, end: seg.end, @@ -41,14 +51,59 @@ export const getSegmentTags = (segment) => (segment.tags || {}); export const sortSegments = (segments) => sortBy(segments, 'start'); +// https://stackoverflow.com/a/30472982/6519037 +export function partitionIntoOverlappingRanges(array, getSegmentStart = (seg) => seg.start, getSegmentEnd = (seg) => seg.end) { + function getMaxEnd(array2) { + // note: this also mutates array2 + array2.sort((a, b) => { + if (getSegmentEnd(a) < getSegmentEnd(b)) return 1; + if (getSegmentEnd(a) > getSegmentEnd(b)) return -1; + return 0; + }); + return getSegmentEnd(array2[0]); + } + + const ret = []; + let g = 0; + ret[g] = [array[0]]; + + for (let i = 1; i < array.length; i += 1) { + if (getSegmentStart(array[i]) >= getSegmentStart(array[i - 1]) && getSegmentStart(array[i]) < getMaxEnd(ret[g])) { + ret[g].push(array[i]); + } else { + g += 1; + ret[g] = [array[i]]; + } + } + + return ret.filter((group) => group.length > 1).map((group) => sortBy(group, (seg) => getSegmentStart(seg))); +} + +export function combineOverlappingSegments(existingSegments, getSegApparentEnd2) { + const partitionedSegments = partitionIntoOverlappingRanges(existingSegments, getSegApparentStart, getSegApparentEnd2); + + return existingSegments.map((existingSegment) => { + const partOfPartition = partitionedSegments.find((partition) => partition.includes(existingSegment)); + if (partOfPartition == null) return existingSegment; // this is not an overlapping segment, pass it through + + const index = partOfPartition.indexOf(existingSegment); + // The first segment is the one with the lowest "start" value, so we use its start value + if (index === 0) { + return { + ...existingSegment, + // but use the segment with the highest "end" value as the end value. + end: sortBy(partOfPartition, (segment) => segment.end)[partOfPartition.length - 1].end, + }; + } + return undefined; // then remove all other segments in this partition group + }).filter((segment) => segment); +} + export function hasAnySegmentOverlap(sortedSegments) { if (sortedSegments.length < 1) return false; - return sortedSegments.some((cutSegment, i) => { - if (i === 0) return false; - const previousSeg = sortedSegments[i - 1]; - return previousSeg.end > cutSegment.start; - }); + const overlappingGroups = partitionIntoOverlappingRanges(sortedSegments); + return overlappingGroups.length > 0; } export function invertSegments(sortedCutSegments, includeFirstSegment, includeLastSegment, duration) { diff --git a/src/segments.test.js b/src/segments.test.js index fa52de75..f80c613e 100644 --- a/src/segments.test.js +++ b/src/segments.test.js @@ -1,4 +1,4 @@ -import { convertSegmentsToChapters } from './segments'; +import { convertSegmentsToChapters, partitionIntoOverlappingRanges, getSegApparentStart, getSegApparentEnd } from './segments'; it('converts segments to chapters with gaps', () => { expect(convertSegmentsToChapters([ @@ -49,3 +49,61 @@ it('converts segments to chapters with single long segment', () => { }, ])).toMatchSnapshot(); }); + +it('detects overlapping segments', () => { + expect(partitionIntoOverlappingRanges([ + { start: 0, end: 1 }, + ])).toEqual([]); + + expect(partitionIntoOverlappingRanges([ + { start: 0, end: 1 }, + { start: 1, end: 2 }, + ])).toEqual([]); + + expect(partitionIntoOverlappingRanges([ + { start: 0, end: 1 }, + { start: 0.5, end: 2 }, + { start: 2, end: 3 }, + ])).toEqual([ + [ + { start: 0, end: 1 }, + { start: 0.5, end: 2 }, + ], + ]); + + expect(partitionIntoOverlappingRanges([ + { start: 0, end: 2 }, + { start: 0.5, end: 1 }, + ])).toEqual([ + [ + { start: 0, end: 2 }, + { start: 0.5, end: 1 }, + ], + ]); + + expect(partitionIntoOverlappingRanges([ + { start: 9, end: 10.50 }, + { start: 11, end: 12 }, + { start: 11.5, end: 12.5 }, + { start: 11.5, end: 13 }, + { start: 15, end: 17 }, + { start: 17, end: 20 }, + { start: 17.5, end: 18.5 }, + { start: 18, end: 19.5 }, + { start: 19, end: 19.5 }, + { start: 19.5, end: 22 }, + { start: 22, end: 25 }, + ])).toEqual([ + [{ start: 11, end: 12 }, { start: 11.5, end: 13 }, { start: 11.5, end: 12.5 }], + [{ start: 17, end: 20 }, { start: 17.5, end: 18.5 }, { start: 18, end: 19.5 }, { start: 19, end: 19.5 }, { start: 19.5, end: 22 }], + ]); +}); + +it('detects overlapping segments, undefined end', () => { + expect(partitionIntoOverlappingRanges([ + { start: 1, end: undefined }, + { start: 1.5, end: undefined }, + ], getSegApparentStart, (seg) => getSegApparentEnd(seg, 2))).toEqual([ + [{ start: 1, end: undefined }, { start: 1.5, end: undefined }], + ]); +}); diff --git a/src/util.js b/src/util.js index bbbba28c..8ac3a5df 100644 --- a/src/util.js +++ b/src/util.js @@ -156,8 +156,6 @@ export const isMasBuild = window.process.mas; export const isWindowsStoreBuild = window.process.windowsStore; export const isStoreBuild = isMasBuild || isWindowsStoreBuild; -export const isDurationValid = (duration) => Number.isFinite(duration) && duration > 0; - export const platform = os.platform(); export const arch = os.arch();