From d5e03784d7d0cd5169b022945a0f0aa7d04aafae Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 21 Feb 2021 19:12:25 +0100 Subject: [PATCH] Implement EDL import Only supports type=0 (cut) also refactor Closes #609 --- public/menu.js | 6 ++++ src/App.jsx | 43 ++++------------------------ src/ExportConfirm.jsx | 3 +- src/SegmentList.jsx | 2 +- src/Timeline.jsx | 2 +- src/TimelineControls.jsx | 2 +- src/components/SetCutpointButton.jsx | 2 +- src/edlFormats.js | 18 ++++++++++++ src/edlFormats.test.js | 40 +++++++++++++++++++++++++- src/edlStore.js | 6 +++- src/segments.js | 43 +++++++++++++++++++++++++++- src/util.js | 16 ----------- src/util/colors.js | 15 ++++++++++ src/{ => util}/random-color.js | 0 14 files changed, 137 insertions(+), 61 deletions(-) create mode 100644 src/util/colors.js rename src/{ => util}/random-color.js (100%) diff --git a/public/menu.js b/public/menu.js index f5077648..0aa16697 100644 --- a/public/menu.js +++ b/public/menu.js @@ -44,6 +44,12 @@ module.exports = (app, mainWindow, newVersion) => { { label: 'Import project', submenu: [ + { + label: 'EDL (MPlayer)', + click() { + mainWindow.webContents.send('importEdlFile', 'mplayer'); + }, + }, { label: 'Text chapters / YouTube', click() { diff --git a/src/App.jsx b/src/App.jsx index b457827e..f46fdd83 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -47,7 +47,7 @@ import { findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl, getDuration, getTimecodeFromStreams, createChaptersFromSegments, } from './ffmpeg'; -import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, saveCsvHuman } from './edlStore'; +import { saveCsv, saveTsv, loadCsv, loadXmeml, loadCue, loadPbf, loadMplayerEdl, saveCsvHuman } from './edlStore'; import { formatYouTube } from './edlFormats'; import { getOutPath, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur, @@ -59,7 +59,7 @@ import { formatDuration } from './util/duration'; import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; -import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor } from './segments'; +import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments } from './segments'; import loadingLottie from './7077-magic-flow.json'; @@ -315,46 +315,13 @@ const App = memo(() => { const jumpCutStart = useCallback(() => seekAbs(currentApparentCutSeg.start), [currentApparentCutSeg.start, seekAbs]); const jumpCutEnd = useCallback(() => seekAbs(currentApparentCutSeg.end), [currentApparentCutSeg.end, seekAbs]); - const sortedCutSegments = useMemo(() => sortBy(apparentCutSegments, 'start'), [apparentCutSegments]); + const sortedCutSegments = useMemo(() => sortSegments(apparentCutSegments), [apparentCutSegments]); const inverseCutSegments = useMemo(() => { if (haveInvalidSegs) return undefined; - if (sortedCutSegments.length < 1) return undefined; - - const foundOverlap = sortedCutSegments.some((cutSegment, i) => { - if (i === 0) return false; - return sortedCutSegments[i - 1].end > cutSegment.start; - }); - - if (foundOverlap) return undefined; if (!isDurationValid(duration)) return undefined; - const ret = []; - - if (sortedCutSegments[0].start > 0) { - ret.push({ - start: 0, - end: sortedCutSegments[0].start, - }); - } - - sortedCutSegments.forEach((cutSegment, i) => { - if (i === 0) return; - ret.push({ - start: sortedCutSegments[i - 1].end, - end: cutSegment.start, - }); - }); - - const last = sortedCutSegments[sortedCutSegments.length - 1]; - if (last.end < duration) { - ret.push({ - start: last.end, - end: duration, - }); - } - - return ret; + return invertSegments(sortedCutSegments, duration); }, [duration, haveInvalidSegs, sortedCutSegments]); const updateSegAtIndex = useCallback((index, newProps) => { @@ -1191,6 +1158,7 @@ const App = memo(() => { else if (type === 'xmeml') edl = await loadXmeml(path); else if (type === 'cue') edl = await loadCue(path); else if (type === 'pbf') edl = await loadPbf(path); + else if (type === 'mplayer') edl = await loadMplayerEdl(path); loadCutSegments(edl); } catch (err) { @@ -1713,6 +1681,7 @@ const App = memo(() => { else if (type === 'xmeml') filters = [{ name: i18n.t('XML files'), extensions: ['xml'] }]; else if (type === 'cue') filters = [{ name: i18n.t('CUE files'), extensions: ['cue'] }]; else if (type === 'pbf') filters = [{ name: i18n.t('PBF files'), extensions: ['pbf'] }]; + else if (type === 'mplayer') filters = [{ name: i18n.t('MPlayer EDL'), extensions: ['*'] }]; const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters }); if (canceled || filePaths.length < 1) return; diff --git a/src/ExportConfirm.jsx b/src/ExportConfirm.jsx index 951d67c0..c8c96ea1 100644 --- a/src/ExportConfirm.jsx +++ b/src/ExportConfirm.jsx @@ -15,7 +15,8 @@ import ToggleExportConfirm from './components/ToggleExportConfirm'; import OutSegTemplateEditor from './components/OutSegTemplateEditor'; import HighlightedText from './components/HighlightedText'; -import { withBlur, toast, getSegColors } from './util'; +import { withBlur, toast } from './util'; +import { getSegColors } from './util/colors'; import { isMov as ffmpegIsMov } from './ffmpeg'; const sheetStyle = { diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx index 4231f50e..ebee1b5e 100644 --- a/src/SegmentList.jsx +++ b/src/SegmentList.jsx @@ -7,7 +7,7 @@ import Swal from 'sweetalert2'; import { useTranslation } from 'react-i18next'; import { saveColor } from './colors'; -import { getSegColors } from './util'; +import { getSegColors } from './util/colors'; const buttonBaseStyle = { margin: '0 3px', borderRadius: 3, color: 'white', cursor: 'pointer', diff --git a/src/Timeline.jsx b/src/Timeline.jsx index 15e6365a..c055a701 100644 --- a/src/Timeline.jsx +++ b/src/Timeline.jsx @@ -10,7 +10,7 @@ import InverseCutSegment from './InverseCutSegment'; import { timelineBackground } from './colors'; -import { getSegColors } from './util'; +import { getSegColors } from './util/colors'; const hammerOptions = { recognizers: {} }; diff --git a/src/TimelineControls.jsx b/src/TimelineControls.jsx index ff0bfacd..f28fffb4 100644 --- a/src/TimelineControls.jsx +++ b/src/TimelineControls.jsx @@ -5,7 +5,7 @@ import { IoMdKey } from 'react-icons/io'; import { useTranslation } from 'react-i18next'; // import useTraceUpdate from 'use-trace-update'; -import { getSegColors } from './util'; +import { getSegColors } from './util/colors'; import { formatDuration, parseDuration } from './util/duration'; import { primaryTextColor } from './colors'; import SetCutpointButton from './components/SetCutpointButton'; diff --git a/src/components/SetCutpointButton.jsx b/src/components/SetCutpointButton.jsx index 528892d1..1dd175d5 100644 --- a/src/components/SetCutpointButton.jsx +++ b/src/components/SetCutpointButton.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import { getSegColors } from '../util'; +import { getSegColors } from '../util/colors'; const SetCutpointButton = ({ currentCutSeg, side, Icon, onClick, title, style }) => { const { diff --git a/src/edlFormats.js b/src/edlFormats.js index 20de1bc8..0bf7af6e 100644 --- a/src/edlFormats.js +++ b/src/edlFormats.js @@ -6,6 +6,7 @@ import pify from 'pify'; import sortBy from 'lodash/sortBy'; import { formatDuration } from './util/duration'; +import { invertSegments, sortSegments } from './segments'; const csvParseAsync = pify(csvParse); @@ -32,6 +33,23 @@ export async function parseCsv(str) { return mapped; } +export async function parseMplayerEdl(text) { + const cutAwaySegments = text.split('\n').map((line) => { + // We only support "Cut" (0) + const match = line.match(/^\s*([^\s]+)\s+([^\s]+)\s+0\s*$/); + if (!match) return undefined; + const start = parseFloat(match[1]); + const end = parseFloat(match[2]); + if (start < 0 || end < 0 || start >= end) throw new Error(i18n.t('Invalid start or end value. Must contain a number of seconds')); + return { start, end }; + }).filter((it) => it); + + if (cutAwaySegments.length === 0) throw new Error(i18n.t('Invalid EDL data found')); + const inverted = invertSegments(sortSegments(cutAwaySegments)); + if (!inverted) throw new Error(i18n.t('Invalid EDL data found')); + return inverted; +} + export function parseCuesheet(cuesheet) { // There are 75 such frames per second of audio. // https://en.wikipedia.org/wiki/Cue_sheet_(computing) diff --git a/src/edlFormats.test.js b/src/edlFormats.test.js index 441d8bad..325e33c9 100644 --- a/src/edlFormats.test.js +++ b/src/edlFormats.test.js @@ -1,4 +1,4 @@ -import { parseYouTube, formatYouTube } from './edlFormats'; +import { parseYouTube, formatYouTube, parseMplayerEdl } from './edlFormats'; it('parseYoutube', () => { const str = ` @@ -58,3 +58,41 @@ it('formatYouTube', () => { '0:00', ]); }); + +// https://kodi.wiki/view/Edit_decision_list +// http://www.mplayerhq.hu/DOCS/HTML/en/edl.html +it('parseMplayerEdl', async () => { + // TODO support more durations: + /* + const str = `\ +5.3 7.1 0 +15 16.7 1 +7:00 13:42 3 +1 4:15.3 2 +12:00.1 2 +`; + const str = `\ +#127 #170 0 +#360 #400 1 +#10080 #19728 3 +#1 #6127 2 +#17282 2 +`; +*/ + + const str = `\ +5.3 7.1 0 +15 16.7 1 +420 822 3 +1 255.3 2 +720.1 2 +`; + + expect(await parseMplayerEdl(str)).toEqual([{ start: 0, end: 5.3 }, { start: 7.1, end: undefined }]); + + const str2 = `\ + 0 1.1 0 +`; + + expect(await parseMplayerEdl(str2)).toEqual([{ start: 1.1, end: undefined }]); +}); diff --git a/src/edlStore.js b/src/edlStore.js index ad8b7d95..8c2a732b 100644 --- a/src/edlStore.js +++ b/src/edlStore.js @@ -1,7 +1,7 @@ import csvStringify from 'csv-stringify'; import pify from 'pify'; -import { parseCuesheet, parseXmeml, parseCsv, parsePbf } from './edlFormats'; +import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats'; import { formatDuration } from './util/duration'; const fs = window.require('fs-extra'); @@ -21,6 +21,10 @@ export async function loadPbf(path) { return parsePbf(await fs.readFile(path, 'utf-8')); } +export async function loadMplayerEdl(path) { + return parseMplayerEdl(await fs.readFile(path, 'utf-8')); +} + export async function loadCue(path) { return parseCuesheet(cueParser.parse(path)); } diff --git a/src/segments.js b/src/segments.js index 6fc929bc..de307078 100644 --- a/src/segments.js +++ b/src/segments.js @@ -1,6 +1,7 @@ import uuid from 'uuid'; +import sortBy from 'lodash/sortBy'; -import { generateColor } from './util'; +import { generateColor } from './util/colors'; export const createSegment = ({ start, end, name } = {}) => ({ start, @@ -32,3 +33,43 @@ export function findSegmentsAtCursor(apparentSegments, currentTime) { }); return indexes; } + +export const sortSegments = (segments) => sortBy(segments, 'start'); + +export function invertSegments(sortedCutSegments, duration) { + if (sortedCutSegments.length < 1) return undefined; + + const foundOverlap = sortedCutSegments.some((cutSegment, i) => { + if (i === 0) return false; + return sortedCutSegments[i - 1].end > cutSegment.start; + }); + + if (foundOverlap) return undefined; + + const ret = []; + + if (sortedCutSegments[0].start > 0) { + ret.push({ + start: 0, + end: sortedCutSegments[0].start, + }); + } + + sortedCutSegments.forEach((cutSegment, i) => { + if (i === 0) return; + ret.push({ + start: sortedCutSegments[i - 1].end, + end: cutSegment.start, + }); + }); + + const last = sortedCutSegments[sortedCutSegments.length - 1]; + if (last.end < duration || duration == null) { + ret.push({ + start: last.end, + end: duration, + }); + } + + return ret; +} diff --git a/src/util.js b/src/util.js index 225e7423..d21a447f 100644 --- a/src/util.js +++ b/src/util.js @@ -2,8 +2,6 @@ import Swal from 'sweetalert2'; import i18n from 'i18next'; import lodashTemplate from 'lodash/template'; -import randomColor from './random-color'; - const path = window.require('path'); const fs = window.require('fs-extra'); const open = window.require('open'); @@ -92,10 +90,6 @@ export function filenamify(name) { return name.replace(/[^0-9a-zA-Z_.]/g, '_'); } -export function generateColor() { - return randomColor(1, 0.95); -} - export function withBlur(cb) { return (e) => { cb(e); @@ -103,16 +97,6 @@ export function withBlur(cb) { }; } -export function getSegColors(seg) { - if (!seg) return {}; - const { color } = seg; - return { - segBgColor: color.alpha(0.5).string(), - segActiveBgColor: color.lighten(0.5).alpha(0.5).string(), - segBorderColor: color.lighten(0.5).string(), - }; -} - export function dragPreventer(ev) { ev.preventDefault(); } diff --git a/src/util/colors.js b/src/util/colors.js new file mode 100644 index 00000000..5df76e05 --- /dev/null +++ b/src/util/colors.js @@ -0,0 +1,15 @@ +import randomColor from './random-color'; + +export function generateColor() { + return randomColor(1, 0.95); +} + +export function getSegColors(seg) { + if (!seg) return {}; + const { color } = seg; + return { + segBgColor: color.alpha(0.5).string(), + segActiveBgColor: color.lighten(0.5).alpha(0.5).string(), + segBorderColor: color.lighten(0.5).string(), + }; +} diff --git a/src/random-color.js b/src/util/random-color.js similarity index 100% rename from src/random-color.js rename to src/util/random-color.js