Mikael Finstad 4 years ago
parent e937cd0979
commit 95e6a5d198
No known key found for this signature in database
GPG Key ID: 25AB36E3E81CBC26

@ -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() {

@ -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 {

@ -161,8 +161,13 @@ const SegmentList = memo(({
let header = t('Segments to export:');
if (segments.length === 0) {
if (invertCutSegments) header = <Trans>You have enabled the &quot;invert segments&quot; mode <FaYinYang style={{ verticalAlign: 'middle' }} /> 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 <FaYinYang style={{ verticalAlign: 'middle', color: primaryTextColor }} /> symbol below to disable this mode.</Trans>;
else header = t('No segments to export.');
if (invertCutSegments) {
header = (
<Trans>You have enabled the &quot;invert segments&quot; mode <FaYinYang style={{ verticalAlign: 'middle' }} /> 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 <FaYinYang style={{ verticalAlign: 'middle', color: primaryTextColor }} /> symbol below to disable this mode. Alternatively you may combine overlapping segments from the menu.</Trans>
);
} else {
header = t('No segments to export.');
}
}
async function onReorderSegs(index) {
@ -254,7 +259,7 @@ const SegmentList = memo(({
animate={{ x: 0 }}
exit={{ x: width }}
>
<div style={{ fontSize: 14, padding: '0 5px', display: 'flex', alignItems: 'center' }} className="no-user-select">
<div style={{ fontSize: 14, padding: '0 5px' }} className="no-user-select">
<FaAngleRight
title={t('Close sidebar')}
size={20}
@ -265,6 +270,7 @@ const SegmentList = memo(({
{header}
</div>
<div style={{ padding: '0 10px', overflowY: 'scroll', flexGrow: 1 }} className="hide-scrollbar">
<ReactSortable list={sortableList} setList={setSortableList} sort={!invertCutSegments} handle=".segment-handle">
{sortableList.map(({ id, seg }, index) => {

@ -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';

@ -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) {

@ -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 }],
]);
});

@ -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();

Loading…
Cancel
Save