Skip to content

Commit

Permalink
Many improvements
Browse files Browse the repository at this point in the history
- Add config option for asking about file open #467
- Implement text/youtube segments import #458
- Implement CUE sheet import #458
- Implement XMEML import (Final Cut Pro / Davinci Resolve)
- Allow import embedded chapters as segments #300
  • Loading branch information
mifi committed Nov 19, 2020
1 parent 6546f08 commit 81eb66f
Show file tree
Hide file tree
Showing 11 changed files with 336 additions and 74 deletions.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.7.0",
"evergreen-ui": "^4.23.0",
"fast-xml-parser": "^3.17.4",
"file-url": "^3.0.0",
"framer-motion": "^1.8.4",
"hammerjs": "^2.0.8",
Expand All @@ -62,6 +63,7 @@
"mousetrap": "^1.6.1",
"p-map": "^3.0.0",
"patch-package": "^6.2.1",
"pify": "^5.0.0",
"pretty-ms": "^6.0.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
Expand All @@ -81,11 +83,11 @@
"wait-on": "^4.0.1"
},
"dependencies": {
"cue-parser": "^0.3.0",
"electron-is-dev": "^0.1.2",
"electron-store": "^5.1.1",
"electron-unhandled": "^3.0.2",
"execa": "^4.0.0",
"fast-xml-parser": "^3.17.4",
"ffmpeg-static": "^4.2.1",
"ffprobe-static": "^3.0.0",
"file-type": "^12.4.0",
Expand Down
2 changes: 2 additions & 0 deletions public/configStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const defaults = {
invertCutSegments: false,
autoExportExtraStreams: true,
askBeforeClose: false,
enableAskForImportChapters: true,
enableAskForFileOpenAction: true,
muted: false,
autoSaveProjectFile: true,
wheelSensitivity: 0.2,
Expand Down
12 changes: 12 additions & 0 deletions public/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,24 @@ module.exports = (app, mainWindow, newVersion) => {
{
label: 'Import project',
submenu: [
{
label: 'Text chapters / YouTube',
click() {
mainWindow.webContents.send('importEdlFile', 'youtube');
},
},
{
label: 'DaVinci Resolve / Final Cut Pro XML',
click() {
mainWindow.webContents.send('importEdlFile', 'xmeml');
},
},
{
label: 'CUE sheet file',
click() {
mainWindow.webContents.send('importEdlFile', 'cue');
},
},
],
},
{
Expand Down
88 changes: 50 additions & 38 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ import {
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails,
readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams,
findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc,
findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
} from './ffmpeg';
import { save as edlStoreSave, load as edlStoreLoad, loadXmeml } from './edlStore';
import { saveCsv, loadCsv, loadXmeml, loadCue } from './edlStore';
import {
getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
promptTimeOffset, generateColor, getOutDir, withBlur, checkDirWriteAccess, dirExists, askForOutDir,
openDirToast, askForHtml5ifySpeed, isMasBuild, isStoreBuild,
openDirToast, askForHtml5ifySpeed, askForYouTubeInput, isMasBuild, isStoreBuild, askForFileOpenAction,
askForImportChapters,
} from './util';
import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n';
Expand Down Expand Up @@ -213,6 +214,10 @@ const App = memo(() => {
useEffect(() => safeSetConfig('autoExportExtraStreams', autoExportExtraStreams), [autoExportExtraStreams]);
const [askBeforeClose, setAskBeforeClose] = useState(configStore.get('askBeforeClose'));
useEffect(() => safeSetConfig('askBeforeClose', askBeforeClose), [askBeforeClose]);
const [enableAskForImportChapters, setEnableAskForImportChapters] = useState(configStore.get('enableAskForImportChapters'));
useEffect(() => safeSetConfig('enableAskForImportChapters', enableAskForImportChapters), [enableAskForImportChapters]);
const [enableAskForFileOpenAction, setEnableAskForFileOpenAction] = useState(configStore.get('enableAskForFileOpenAction'));
useEffect(() => safeSetConfig('enableAskForFileOpenAction', enableAskForFileOpenAction), [enableAskForFileOpenAction]);
const [muted, setMuted] = useState(configStore.get('muted'));
useEffect(() => safeSetConfig('muted', muted), [muted]);
const [autoSaveProjectFile, setAutoSaveProjectFile] = useState(configStore.get('autoSaveProjectFile'));
Expand Down Expand Up @@ -566,7 +571,7 @@ const App = memo(() => {
return;
} */

await edlStoreSave(edlFilePath, debouncedCutSegments);
await saveCsv(edlFilePath, debouncedCutSegments);
lastSavedCutSegmentsRef.current = debouncedCutSegments;
} catch (err) {
errorToast(i18n.t('Unable to save project file'));
Expand Down Expand Up @@ -1106,29 +1111,26 @@ const App = memo(() => {
}, []);

const loadCutSegments = useCallback((edl) => {
const allRowsValid = edl
.every(row => row.start === undefined || row.end === undefined || row.start < row.end);
if (edl.length === 0) throw new Error();
const allRowsValid = edl.every(row => row.start === undefined || row.end === undefined || row.start < row.end);

if (!allRowsValid) {
throw new Error(i18n.t('Invalid start or end values for one or more segments'));
}
if (!allRowsValid) throw new Error(i18n.t('Invalid start or end values for one or more segments'));

cutSegmentsHistory.go(0);
setCutSegments(edl.map(createSegment));
}, [cutSegmentsHistory, setCutSegments]);

const loadEdlFile = useCallback(async (edlPath, type = 'csv') => {
const loadEdlFile = useCallback(async (path, type = 'csv') => {
try {
let storedEdl;
if (type === 'csv') storedEdl = await edlStoreLoad(edlPath);
else if (type === 'xmeml') storedEdl = await loadXmeml(edlPath);
let edl;
if (type === 'csv') edl = await loadCsv(path);
else if (type === 'xmeml') edl = await loadXmeml(path);
else if (type === 'cue') edl = await loadCue(path);

loadCutSegments(storedEdl);
loadCutSegments(edl);
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('EDL load failed', err);
errorToast(`${i18n.t('Failed to load project file')} (${err.message})`);
}
console.error('EDL load failed', err);
errorToast(`${i18n.t('Failed to load project')} (${err.message})`);
}
}, [loadCutSegments]);

Expand Down Expand Up @@ -1216,7 +1218,17 @@ const App = memo(() => {
await createDummyVideo(cod, fp);
}

await loadEdlFile(getEdlFilePath(fp));
const openedFileEdlPath = getEdlFilePath(fp);

if (await exists(openedFileEdlPath)) {
await loadEdlFile(openedFileEdlPath);
} else {
const edl = await tryReadChaptersToEdl(fp);
if (edl.length > 0 && enableAskForImportChapters && (await askForImportChapters())) {
console.log('Read chapters', edl);
loadCutSegments(edl);
}
}
} catch (err) {
if (err.exitCode === 1 || err.code === 'ENOENT') {
errorToast(i18n.t('Unsupported file'));
Expand All @@ -1227,7 +1239,7 @@ const App = memo(() => {
} finally {
setWorking();
}
}, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath]);
}, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath, loadCutSegments, enableAskForImportChapters]);

const toggleHelp = useCallback(() => setHelpVisible(val => !val), []);
const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []);
Expand Down Expand Up @@ -1363,27 +1375,15 @@ const App = memo(() => {
return;
}

const { value } = await Swal.fire({
title: i18n.t('You opened a new file. What do you want to do?'),
icon: 'question',
input: 'radio',
inputValue: 'open',
showCancelButton: true,
customClass: { input: 'swal2-losslesscut-radio' },
inputOptions: {
open: i18n.t('Open the file instead of the current one'),
add: i18n.t('Include all tracks from the new file'),
},
inputValidator: (v) => !v && i18n.t('You need to choose something!'),
});
const openFileResponse = enableAskForFileOpenAction ? await askForFileOpenAction() : 'open';

if (value === 'open') {
if (openFileResponse === 'open') {
load({ filePath: firstFile, customOutDir: newCustomOutDir });
} else if (value === 'add') {
} else if (openFileResponse === 'add') {
addStreamSourceFile(firstFile);
setStreamsSelectorShown(true);
}
}, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess]);
}, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess, enableAskForFileOpenAction]);

const onDrop = useCallback(async (ev) => {
ev.preventDefault();
Expand Down Expand Up @@ -1525,7 +1525,7 @@ const App = memo(() => {
errorToast(i18n.t('File exists, bailing'));
return;
}
await edlStoreSave(fp, cutSegments);
await saveCsv(fp, cutSegments);
} catch (err) {
errorToast(i18n.t('Failed to export CSV'));
console.error('Failed to export CSV', err);
Expand All @@ -1538,9 +1538,16 @@ const App = memo(() => {
return;
}

if (type === 'youtube') {
const edl = await askForYouTubeInput();
if (edl.length > 0) loadCutSegments(edl);
return;
}

let filters;
if (type === 'csv') filters = [{ name: i18n.t('CSV files'), extensions: ['csv'] }];
else if (type === 'xmeml') filters = [{ name: i18n.t('XML files'), extensions: ['xml'] }];
else if (type === 'cue') filters = [{ name: i18n.t('CUE files'), extensions: ['cue'] }];

const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
if (canceled || filePaths.length < 1) return;
Expand Down Expand Up @@ -1650,6 +1657,7 @@ const App = memo(() => {
mergeFiles, outputDir, filePath, isFileOpened, customOutDir, startTimeOffset, html5ifyCurrentFile,
createDummyVideo, resetState, extractAllStreams, userOpenFiles, cutSegmentsHistory, openSendReportDialogWithState,
loadEdlFile, cutSegments, edlFilePath, askBeforeClose, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal,
loadCutSegments,
]);

async function showAddStreamSourceDialog() {
Expand Down Expand Up @@ -1735,6 +1743,10 @@ const App = memo(() => {
setTimecodeShowFrames={setTimecodeShowFrames}
askBeforeClose={askBeforeClose}
setAskBeforeClose={setAskBeforeClose}
enableAskForImportChapters={enableAskForImportChapters}
setEnableAskForImportChapters={setEnableAskForImportChapters}
enableAskForFileOpenAction={enableAskForFileOpenAction}
setEnableAskForFileOpenAction={setEnableAskForFileOpenAction}
ffmpegExperimental={ffmpegExperimental}
setFfmpegExperimental={setFfmpegExperimental}
invertTimelineScroll={invertTimelineScroll}
Expand All @@ -1747,7 +1759,7 @@ const App = memo(() => {
renderCaptureFormatButton={renderCaptureFormatButton}
onWheelTunerRequested={onWheelTunerRequested}
/>
), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, renderOutFmt, timecodeShowFrames, changeOutDir, onWheelTunerRequested, language, invertTimelineScroll, ffmpegExperimental, setFfmpegExperimental]);
), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, renderOutFmt, timecodeShowFrames, changeOutDir, onWheelTunerRequested, language, invertTimelineScroll, ffmpegExperimental, setFfmpegExperimental, enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction]);

useEffect(() => {
if (!isStoreBuild) loadMifiLink().then(setMifiLink);
Expand Down
23 changes: 23 additions & 0 deletions src/Settings.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const Settings = memo(({
autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose,
renderOutFmt, AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, language, setLanguage,
invertTimelineScroll, setInvertTimelineScroll, ffmpegExperimental, setFfmpegExperimental,
enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction,
}) => {
const { t } = useTranslation();

Expand Down Expand Up @@ -187,6 +188,28 @@ const Settings = memo(({
/>
</Table.TextCell>
</Row>

<Row>
<KeyCell>{t('Ask about importing chapters from opened file?')}</KeyCell>
<Table.TextCell>
<Checkbox
label={t('Ask about chapters')}
checked={enableAskForImportChapters}
onChange={e => setEnableAskForImportChapters(e.target.checked)}
/>
</Table.TextCell>
</Row>

<Row>
<KeyCell>{t('Ask about what to do when opening a new file when another file is already already open?')}</KeyCell>
<Table.TextCell>
<Checkbox
label={t('Ask on file open')}
checked={enableAskForFileOpenAction}
onChange={e => setEnableAskForFileOpenAction(e.target.checked)}
/>
</Table.TextCell>
</Row>
</Fragment>
);
});
Expand Down
96 changes: 96 additions & 0 deletions src/edlFormats.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import fastXmlParser from 'fast-xml-parser';
import i18n from 'i18next';

import csvParse from 'csv-parse';
import pify from 'pify';
import sortBy from 'lodash/sortBy';

const csvParseAsync = pify(csvParse);

export async function parseCsv(str) {
const rows = await csvParseAsync(str, {});
if (rows.length === 0) throw new Error(i18n.t('No rows found'));
if (!rows.every(row => row.length === 3)) throw new Error(i18n.t('One or more rows does not have 3 columns'));

const mapped = rows
.map(([start, end, name]) => ({
start: start === '' ? undefined : parseFloat(start, 10),
end: end === '' ? undefined : parseFloat(end, 10),
name,
}));

if (!mapped.every(({ start, end }) => (
(start === undefined || !Number.isNaN(start))
&& (end === undefined || !Number.isNaN(end))
))) {
console.log(mapped);
throw new Error(i18n.t('Invalid start or end value. Must contain a number of seconds'));
}

return mapped;
}

export function parseCuesheet(cuesheet) {
// There are 75 such frames per second of audio.
// https://en.wikipedia.org/wiki/Cue_sheet_(computing)
const fps = 75;

const { tracks } = cuesheet.files[0];

function parseTime(track) {
const index = track.indexes[0];
if (!index) return undefined;
const { time } = index;
if (!time) return undefined;

return (time.min * 60) + time.sec + time.frame / fps;
}

return tracks.map((track, i) => {
const nextTrack = tracks[i + 1];
const end = nextTrack && parseTime(nextTrack);

return { name: track.title, start: parseTime(track), end };
});
}

// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/VersionsoftheInterchangeFormat/VersionsoftheInterchangeFormat.html
export function parseXmeml(xmlStr) {
const xml = fastXmlParser.parse(xmlStr);
// TODO maybe support media.audio also?
return xml.xmeml.project.children.sequence.media.video.track.clipitem.map((item) => ({ start: item.start / item.rate.timebase, end: item.end / item.rate.timebase }));
}

export function parseYouTube(str) {
const regex = /(?:([0-9]{2,}):)?([0-9]{2}):([0-9]{2})(?:\.([0-9]{3}))?[^\S\n]+([^\n]*)\n/g;

const lines = [];

function parseLine(match) {
if (!match) return undefined;
const [, hourStr, minStr, secStr, msStr, name] = match;
const hour = hourStr != null ? parseInt(hourStr, 10) : 0;
const min = parseInt(minStr, 10);
const sec = parseInt(secStr, 10);
const ms = msStr != null ? parseInt(msStr, 10) : 0;

const time = (((hour * 60) + min) * 60 + sec) + ms / 1000;

return { time, name };
}

let m;
// eslint-disable-next-line no-cond-assign
while ((m = regex.exec(`${str}\n`))) {
lines.push(parseLine(m));
}

const linesSorted = sortBy(lines, (l) => l.time);

const edl = linesSorted.map((line, i) => {
const nextLine = linesSorted[i + 1];
return { start: line.time, end: nextLine && nextLine.time, name: line.name };
});

return edl.filter((ed) => ed.start !== ed.end);
}
Loading

0 comments on commit 81eb66f

Please sign in to comment.