diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9e7e4e8e..1805ed43 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -61,7 +61,7 @@ import { mapRecommendedDefaultFormat, getFfCommandLine, } from './ffmpeg'; -import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams } from './util/streams'; +import { shouldCopyStreamByDefault, getAudioStreams, getRealVideoStreams, isAudioDefinitelyNotSupported, willPlayerProperlyHandleVideo, doesPlayerSupportHevcPlayback, getSubtitleStreams, enableVideoTrack, enableAudioTrack, canHtml5PlayerPlayStreams, isMatroska } from './util/streams'; import { exportEdlFile, readEdlFile, loadLlcProject, askForEdlImport } from './edlStore'; import { formatYouTube, getFrameCountRaw, formatTsvHuman } from './edlFormats'; import { @@ -83,7 +83,7 @@ import { askForHtml5ifySpeed } from './dialogs/html5ify'; import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers } from './segments'; +import { sortSegments, convertSegmentsToChaptersWithGaps, hasAnySegmentOverlap, isDurationValid, getPlaybackAction, getSegmentTags, filterNonMarkers } from './segments'; import { generateOutSegFileNames as generateOutSegFileNamesRaw, generateMergedFileNames as generateMergedFileNamesRaw, defaultOutSegTemplate, defaultCutMergedFileTemplate } from './util/outputNameTemplate'; import { rightBarWidth, leftBarWidth, ffmpegExtractWindow, zoomMax } from './util/constants'; import BigWaveform from './components/BigWaveform'; @@ -443,7 +443,7 @@ function App() { setRotation((r) => (r + 90) % 450); setHideMediaSourcePlayer(false); // Matroska is known not to work, so we warn user. See https://github.com/mifi/lossless-cut/discussions/661 - const supportsRotation = !(fileFormat != null && ['matroska', 'webm'].includes(fileFormat)); + const supportsRotation = !isMatroska(fileFormat); if (!supportsRotation) showNotification({ text: i18n.t('Lossless rotation might not work with this file format. You may try changing to MP4') }); }, [fileFormat, showNotification]); @@ -1085,7 +1085,8 @@ function App() { errorToast(i18n.t('Make sure you have no overlapping segments.')); return; } - chaptersToAdd = convertSegmentsToChapters(sortedSegments); + // matroska supports gaps, so we can use segments directly + chaptersToAdd = isMatroska(fileFormat) ? sortedSegments : convertSegmentsToChaptersWithGaps(sortedSegments); } console.log('outSegTemplateOrDefault', outSegTemplateOrDefault); diff --git a/src/renderer/src/segments.test.ts b/src/renderer/src/segments.test.ts index 8e96c53f..66457271 100644 --- a/src/renderer/src/segments.test.ts +++ b/src/renderer/src/segments.test.ts @@ -1,9 +1,9 @@ import { test, it, expect, describe } from 'vitest'; -import { convertSegmentsToChapters, partitionIntoOverlappingRanges, formatSegNum, combineOverlappingSegments, invertSegments } from './segments'; +import { convertSegmentsToChaptersWithGaps, partitionIntoOverlappingRanges, formatSegNum, combineOverlappingSegments, invertSegments } from './segments'; it('converts segments to chapters with gaps', () => { - expect(convertSegmentsToChapters([ + expect(convertSegmentsToChaptersWithGaps([ { start: 104.612, end: 189.053, @@ -27,8 +27,8 @@ it('converts segments to chapters with gaps', () => { ])).toMatchSnapshot(); }); -it('converts segments to chapters with no gaps', () => { - expect(convertSegmentsToChapters([ +it('converts segments to chapters with no gaps when adjacent', () => { + expect(convertSegmentsToChaptersWithGaps([ { start: 0, end: 2, @@ -43,7 +43,7 @@ it('converts segments to chapters with no gaps', () => { }); it('converts segments to chapters with single long segment', () => { - expect(convertSegmentsToChapters([ + expect(convertSegmentsToChaptersWithGaps([ { start: 0, end: 1, diff --git a/src/renderer/src/segments.ts b/src/renderer/src/segments.ts index 2a39bd79..4ab51224 100644 --- a/src/renderer/src/segments.ts +++ b/src/renderer/src/segments.ts @@ -4,7 +4,7 @@ import minBy from 'lodash/minBy'; import maxBy from 'lodash/maxBy'; import invariant from 'tiny-invariant'; -import { PlaybackMode, SegmentBase, SegmentTags, SegmentToExport, StateSegment } from './types'; +import { DefiniteSegmentBase, PlaybackMode, SegmentBase, SegmentTags, SegmentToExport, StateSegment } from './types'; export const isDurationValid = (duration?: number): duration is number => duration != null && Number.isFinite(duration) && duration > 0; @@ -208,19 +208,16 @@ export function invertSegments( return ret.filter(({ start, end }) => end == null || end > start); } -// because chapters need to be contiguous, we need to insert gaps in-between -export function convertSegmentsToChapters(sortedSegments: { start: number, end: number, name?: string | undefined }[]) { - if (sortedSegments.length === 0) return []; - if (hasAnySegmentOverlap(sortedSegments)) throw new Error('Segments cannot overlap'); - +// because chapters need to be contiguous in formats like MP4, we insert "gap" segments in-between, so that end times will be correct +export function convertSegmentsToChaptersWithGaps(sortedSegments: (DefiniteSegmentBase & { name?: string | undefined })[]) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const invertedSegments = invertSegments(sortedSegments, true, false).map(({ end, name: _ignored, ...seg }) => { + const invertedSegmentsWithoutName = invertSegments(sortedSegments, true, false).map(({ end, name: _ignored, ...seg }) => { invariant(end != null); // to please typescript return { ...seg, end }; }); // inverted segments will be "gap" segments. Merge together with normal segments - return sortSegments([...sortedSegments, ...invertedSegments]); + return sortSegments([...sortedSegments, ...invertedSegmentsWithoutName]); } export function getPlaybackAction({ playbackMode, currentTime, playingSegment }: { diff --git a/src/renderer/src/util/streams.ts b/src/renderer/src/util/streams.ts index 59ee3f6d..1f61a7d5 100644 --- a/src/renderer/src/util/streams.ts +++ b/src/renderer/src/util/streams.ts @@ -101,6 +101,8 @@ export function getActiveDisposition(disposition: FFprobeStreamDisposition | und export const isMov = (format: string | undefined) => format != null && ['ismv', 'ipod', 'mp4', 'mov'].includes(format); +export const isMatroska = (format: string | undefined) => format != null && ['matroska', 'webm'].includes(format); + type GetVideoArgsFn = (a: { streamIndex: number, outputIndex: number }) => string[] | undefined; function getPerStreamFlags({ stream, outputIndex, outFormat, manuallyCopyDisposition = false, getVideoArgs = () => undefined, areWeCutting }: {