Implement EDL import

Only supports type=0 (cut)
also refactor

Closes #609
pull/699/head
Mikael Finstad 2021-02-21 19:12:25 +01:00
rodzic f3ab17100d
commit d5e03784d7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
14 zmienionych plików z 137 dodań i 61 usunięć

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -10,7 +10,7 @@ import InverseCutSegment from './InverseCutSegment';
import { timelineBackground } from './colors';
import { getSegColors } from './util';
import { getSegColors } from './util/colors';
const hammerOptions = { recognizers: {} };

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

15
src/util/colors.js 100644
Wyświetl plik

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