Many improvements

- 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
pull/471/head
Mikael Finstad 6 years ago
parent 6546f080d2
commit 81eb66faa2

@ -50,6 +50,7 @@
"eslint-plugin-react": "^7.14.3", "eslint-plugin-react": "^7.14.3",
"eslint-plugin-react-hooks": "^1.7.0", "eslint-plugin-react-hooks": "^1.7.0",
"evergreen-ui": "^4.23.0", "evergreen-ui": "^4.23.0",
"fast-xml-parser": "^3.17.4",
"file-url": "^3.0.0", "file-url": "^3.0.0",
"framer-motion": "^1.8.4", "framer-motion": "^1.8.4",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
@ -62,6 +63,7 @@
"mousetrap": "^1.6.1", "mousetrap": "^1.6.1",
"p-map": "^3.0.0", "p-map": "^3.0.0",
"patch-package": "^6.2.1", "patch-package": "^6.2.1",
"pify": "^5.0.0",
"pretty-ms": "^6.0.0", "pretty-ms": "^6.0.0",
"react": "^16.12.0", "react": "^16.12.0",
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
@ -81,11 +83,11 @@
"wait-on": "^4.0.1" "wait-on": "^4.0.1"
}, },
"dependencies": { "dependencies": {
"cue-parser": "^0.3.0",
"electron-is-dev": "^0.1.2", "electron-is-dev": "^0.1.2",
"electron-store": "^5.1.1", "electron-store": "^5.1.1",
"electron-unhandled": "^3.0.2", "electron-unhandled": "^3.0.2",
"execa": "^4.0.0", "execa": "^4.0.0",
"fast-xml-parser": "^3.17.4",
"ffmpeg-static": "^4.2.1", "ffmpeg-static": "^4.2.1",
"ffprobe-static": "^3.0.0", "ffprobe-static": "^3.0.0",
"file-type": "^12.4.0", "file-type": "^12.4.0",

@ -10,6 +10,8 @@ const defaults = {
invertCutSegments: false, invertCutSegments: false,
autoExportExtraStreams: true, autoExportExtraStreams: true,
askBeforeClose: false, askBeforeClose: false,
enableAskForImportChapters: true,
enableAskForFileOpenAction: true,
muted: false, muted: false,
autoSaveProjectFile: true, autoSaveProjectFile: true,
wheelSensitivity: 0.2, wheelSensitivity: 0.2,

@ -38,12 +38,24 @@ module.exports = (app, mainWindow, newVersion) => {
{ {
label: 'Import project', label: 'Import project',
submenu: [ submenu: [
{
label: 'Text chapters / YouTube',
click() {
mainWindow.webContents.send('importEdlFile', 'youtube');
},
},
{ {
label: 'DaVinci Resolve / Final Cut Pro XML', label: 'DaVinci Resolve / Final Cut Pro XML',
click() { click() {
mainWindow.webContents.send('importEdlFile', 'xmeml'); mainWindow.webContents.send('importEdlFile', 'xmeml');
}, },
}, },
{
label: 'CUE sheet file',
click() {
mainWindow.webContents.send('importEdlFile', 'cue');
},
},
], ],
}, },
{ {

@ -41,13 +41,14 @@ import {
defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd, defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd,
getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails, getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails,
readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams, readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams,
findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl,
} from './ffmpeg'; } from './ffmpeg';
import { save as edlStoreSave, load as edlStoreLoad, loadXmeml } from './edlStore'; import { saveCsv, loadCsv, loadXmeml, loadCue } from './edlStore';
import { import {
getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
promptTimeOffset, generateColor, getOutDir, withBlur, checkDirWriteAccess, dirExists, askForOutDir, promptTimeOffset, generateColor, getOutDir, withBlur, checkDirWriteAccess, dirExists, askForOutDir,
openDirToast, askForHtml5ifySpeed, isMasBuild, isStoreBuild, openDirToast, askForHtml5ifySpeed, askForYouTubeInput, isMasBuild, isStoreBuild, askForFileOpenAction,
askForImportChapters,
} from './util'; } from './util';
import { openSendReportDialog } from './reporting'; import { openSendReportDialog } from './reporting';
import { fallbackLng } from './i18n'; import { fallbackLng } from './i18n';
@ -213,6 +214,10 @@ const App = memo(() => {
useEffect(() => safeSetConfig('autoExportExtraStreams', autoExportExtraStreams), [autoExportExtraStreams]); useEffect(() => safeSetConfig('autoExportExtraStreams', autoExportExtraStreams), [autoExportExtraStreams]);
const [askBeforeClose, setAskBeforeClose] = useState(configStore.get('askBeforeClose')); const [askBeforeClose, setAskBeforeClose] = useState(configStore.get('askBeforeClose'));
useEffect(() => safeSetConfig('askBeforeClose', askBeforeClose), [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')); const [muted, setMuted] = useState(configStore.get('muted'));
useEffect(() => safeSetConfig('muted', muted), [muted]); useEffect(() => safeSetConfig('muted', muted), [muted]);
const [autoSaveProjectFile, setAutoSaveProjectFile] = useState(configStore.get('autoSaveProjectFile')); const [autoSaveProjectFile, setAutoSaveProjectFile] = useState(configStore.get('autoSaveProjectFile'));
@ -566,7 +571,7 @@ const App = memo(() => {
return; return;
} */ } */
await edlStoreSave(edlFilePath, debouncedCutSegments); await saveCsv(edlFilePath, debouncedCutSegments);
lastSavedCutSegmentsRef.current = debouncedCutSegments; lastSavedCutSegmentsRef.current = debouncedCutSegments;
} catch (err) { } catch (err) {
errorToast(i18n.t('Unable to save project file')); errorToast(i18n.t('Unable to save project file'));
@ -1106,29 +1111,26 @@ const App = memo(() => {
}, []); }, []);
const loadCutSegments = useCallback((edl) => { const loadCutSegments = useCallback((edl) => {
const allRowsValid = edl if (edl.length === 0) throw new Error();
.every(row => row.start === undefined || row.end === undefined || row.start < row.end); const allRowsValid = edl.every(row => row.start === undefined || row.end === undefined || row.start < row.end);
if (!allRowsValid) { if (!allRowsValid) throw new Error(i18n.t('Invalid start or end values for one or more segments'));
throw new Error(i18n.t('Invalid start or end values for one or more segments'));
}
cutSegmentsHistory.go(0); cutSegmentsHistory.go(0);
setCutSegments(edl.map(createSegment)); setCutSegments(edl.map(createSegment));
}, [cutSegmentsHistory, setCutSegments]); }, [cutSegmentsHistory, setCutSegments]);
const loadEdlFile = useCallback(async (edlPath, type = 'csv') => { const loadEdlFile = useCallback(async (path, type = 'csv') => {
try { try {
let storedEdl; let edl;
if (type === 'csv') storedEdl = await edlStoreLoad(edlPath); if (type === 'csv') edl = await loadCsv(path);
else if (type === 'xmeml') storedEdl = await loadXmeml(edlPath); else if (type === 'xmeml') edl = await loadXmeml(path);
else if (type === 'cue') edl = await loadCue(path);
loadCutSegments(storedEdl); loadCutSegments(edl);
} catch (err) { } catch (err) {
if (err.code !== 'ENOENT') { console.error('EDL load failed', err);
console.error('EDL load failed', err); errorToast(`${i18n.t('Failed to load project')} (${err.message})`);
errorToast(`${i18n.t('Failed to load project file')} (${err.message})`);
}
} }
}, [loadCutSegments]); }, [loadCutSegments]);
@ -1216,7 +1218,17 @@ const App = memo(() => {
await createDummyVideo(cod, fp); 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) { } catch (err) {
if (err.exitCode === 1 || err.code === 'ENOENT') { if (err.exitCode === 1 || err.code === 'ENOENT') {
errorToast(i18n.t('Unsupported file')); errorToast(i18n.t('Unsupported file'));
@ -1227,7 +1239,7 @@ const App = memo(() => {
} finally { } finally {
setWorking(); setWorking();
} }
}, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath]); }, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath, loadCutSegments, enableAskForImportChapters]);
const toggleHelp = useCallback(() => setHelpVisible(val => !val), []); const toggleHelp = useCallback(() => setHelpVisible(val => !val), []);
const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []); const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []);
@ -1363,27 +1375,15 @@ const App = memo(() => {
return; return;
} }
const { value } = await Swal.fire({ const openFileResponse = enableAskForFileOpenAction ? await askForFileOpenAction() : 'open';
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!'),
});
if (value === 'open') { if (openFileResponse === 'open') {
load({ filePath: firstFile, customOutDir: newCustomOutDir }); load({ filePath: firstFile, customOutDir: newCustomOutDir });
} else if (value === 'add') { } else if (openFileResponse === 'add') {
addStreamSourceFile(firstFile); addStreamSourceFile(firstFile);
setStreamsSelectorShown(true); setStreamsSelectorShown(true);
} }
}, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess]); }, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess, enableAskForFileOpenAction]);
const onDrop = useCallback(async (ev) => { const onDrop = useCallback(async (ev) => {
ev.preventDefault(); ev.preventDefault();
@ -1525,7 +1525,7 @@ const App = memo(() => {
errorToast(i18n.t('File exists, bailing')); errorToast(i18n.t('File exists, bailing'));
return; return;
} }
await edlStoreSave(fp, cutSegments); await saveCsv(fp, cutSegments);
} catch (err) { } catch (err) {
errorToast(i18n.t('Failed to export CSV')); errorToast(i18n.t('Failed to export CSV'));
console.error('Failed to export CSV', err); console.error('Failed to export CSV', err);
@ -1538,9 +1538,16 @@ const App = memo(() => {
return; return;
} }
if (type === 'youtube') {
const edl = await askForYouTubeInput();
if (edl.length > 0) loadCutSegments(edl);
return;
}
let filters; let filters;
if (type === 'csv') filters = [{ name: i18n.t('CSV files'), extensions: ['csv'] }]; 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 === '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 }); const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters });
if (canceled || filePaths.length < 1) return; if (canceled || filePaths.length < 1) return;
@ -1650,6 +1657,7 @@ const App = memo(() => {
mergeFiles, outputDir, filePath, isFileOpened, customOutDir, startTimeOffset, html5ifyCurrentFile, mergeFiles, outputDir, filePath, isFileOpened, customOutDir, startTimeOffset, html5ifyCurrentFile,
createDummyVideo, resetState, extractAllStreams, userOpenFiles, cutSegmentsHistory, openSendReportDialogWithState, createDummyVideo, resetState, extractAllStreams, userOpenFiles, cutSegmentsHistory, openSendReportDialogWithState,
loadEdlFile, cutSegments, edlFilePath, askBeforeClose, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal, loadEdlFile, cutSegments, edlFilePath, askBeforeClose, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal,
loadCutSegments,
]); ]);
async function showAddStreamSourceDialog() { async function showAddStreamSourceDialog() {
@ -1735,6 +1743,10 @@ const App = memo(() => {
setTimecodeShowFrames={setTimecodeShowFrames} setTimecodeShowFrames={setTimecodeShowFrames}
askBeforeClose={askBeforeClose} askBeforeClose={askBeforeClose}
setAskBeforeClose={setAskBeforeClose} setAskBeforeClose={setAskBeforeClose}
enableAskForImportChapters={enableAskForImportChapters}
setEnableAskForImportChapters={setEnableAskForImportChapters}
enableAskForFileOpenAction={enableAskForFileOpenAction}
setEnableAskForFileOpenAction={setEnableAskForFileOpenAction}
ffmpegExperimental={ffmpegExperimental} ffmpegExperimental={ffmpegExperimental}
setFfmpegExperimental={setFfmpegExperimental} setFfmpegExperimental={setFfmpegExperimental}
invertTimelineScroll={invertTimelineScroll} invertTimelineScroll={invertTimelineScroll}
@ -1747,7 +1759,7 @@ const App = memo(() => {
renderCaptureFormatButton={renderCaptureFormatButton} renderCaptureFormatButton={renderCaptureFormatButton}
onWheelTunerRequested={onWheelTunerRequested} 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(() => { useEffect(() => {
if (!isStoreBuild) loadMifiLink().then(setMifiLink); if (!isStoreBuild) loadMifiLink().then(setMifiLink);

@ -8,6 +8,7 @@ const Settings = memo(({
autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose, autoSaveProjectFile, setAutoSaveProjectFile, timecodeShowFrames, setTimecodeShowFrames, askBeforeClose, setAskBeforeClose,
renderOutFmt, AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, language, setLanguage, renderOutFmt, AutoExportToggler, renderCaptureFormatButton, onWheelTunerRequested, language, setLanguage,
invertTimelineScroll, setInvertTimelineScroll, ffmpegExperimental, setFfmpegExperimental, invertTimelineScroll, setInvertTimelineScroll, ffmpegExperimental, setFfmpegExperimental,
enableAskForImportChapters, setEnableAskForImportChapters, enableAskForFileOpenAction, setEnableAskForFileOpenAction,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -187,6 +188,28 @@ const Settings = memo(({
/> />
</Table.TextCell> </Table.TextCell>
</Row> </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> </Fragment>
); );
}); });

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

@ -0,0 +1,44 @@
/* eslint-disable no-undef */
import { parseYouTube } from './edlFormats';
it('parseYoutube', () => {
const str = `
Jump to chapters:
00:00 Test 1
00:01 Test 2
00:02 00:57 double
00:01:01 Test 3
01:01:01.012 Test 4
00:01:01.012 Test 5
01:01.012 Test 6
:01:02.012 Test 7
00:57:01.0123 Invalid 2
00:57:01. Invalid 3
01:15: Invalid 4
0132 Invalid 5
00:03
00:04
00:05
`;
const edl = parseYouTube(str);
expect(edl).toEqual([
{ start: 0, end: 1, name: 'Test 1' },
{ start: 1, end: 2, name: 'Test 2' },
{ start: 2, end: 4, name: '00:57 double' },
{ start: 4, end: 5, name: '' },
{ start: 5, end: 61, name: '' },
{ start: 61, end: 61.012, name: 'Test 3' },
{ start: 61.012, end: 62.012, name: 'Test 6' },
{ start: 62.012, end: 3661.012, name: 'Test 7' },
{ start: 3661.012, end: undefined, name: 'Test 4' },
]);
});
it('parseYouTube eol', () => {
const str = ' 00:00 Test 1\n00:01 Test 2';
const edl = parseYouTube(str);
expect(edl).toEqual([
{ start: 0, end: 1, name: 'Test 1' },
{ start: 1, end: undefined, name: 'Test 2' },
]);
});

@ -1,48 +1,28 @@
import parse from 'csv-parse'; import csvStringify from 'csv-stringify';
import stringify from 'csv-stringify'; import pify from 'pify';
import i18n from 'i18next';
import fastXmlParser from 'fast-xml-parser';
const fs = window.require('fs-extra'); import { parseCuesheet, parseXmeml, parseCsv } from './edlFormats';
const { promisify } = window.require('util');
const stringifyAsync = promisify(stringify);
const parseAsync = promisify(parse);
export async function load(path) {
const str = await fs.readFile(path, 'utf-8');
const rows = await parseAsync(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 const fs = window.require('fs-extra');
.map(([start, end, name]) => ({ const cueParser = window.require('cue-parser');
start: start === '' ? undefined : parseFloat(start, 10),
end: end === '' ? undefined : parseFloat(end, 10),
name,
}));
if (!mapped.every(({ start, end }) => ( const csvStringifyAsync = pify(csvStringify);
(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 async function loadCsv(path) {
return parseCsv(await fs.readFile(path, 'utf-8'));
} }
// https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/FinalCutPro_XML/VersionsoftheInterchangeFormat/VersionsoftheInterchangeFormat.html
export async function loadXmeml(path) { export async function loadXmeml(path) {
const xml = fastXmlParser.parse(await fs.readFile(path, 'utf-8')); return parseXmeml(await fs.readFile(path, 'utf-8'));
// 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 async function loadCue(path) {
return parseCuesheet(cueParser.parse(path));
} }
export async function save(path, cutSegments) { export async function saveCsv(path, cutSegments) {
console.log('Saving', path); console.log('Saving', path);
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]); const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
const str = await stringifyAsync(rows); const str = await csvStringifyAsync(rows);
await fs.writeFile(path, str); await fs.writeFile(path, str);
} }

@ -344,6 +344,28 @@ export async function getDuration(filePath) {
return parseFloat(JSON.parse(stdout).format.duration); return parseFloat(JSON.parse(stdout).format.duration);
} }
export async function tryReadChaptersToEdl(filePath) {
try {
const { stdout } = await runFfprobe(['-i', filePath, '-show_chapters', '-print_format', 'json']);
return JSON.parse(stdout).chapters.map((chapter) => {
const start = parseInt(chapter.start_time, 10);
const end = parseInt(chapter.end_time, 10);
if (Number.isNaN(start) || Number.isNaN(end)) return undefined;
const name = chapter.tags && typeof chapter.tags.title === 'string' ? chapter.tags.title : undefined;
return {
start,
end,
name,
};
}).filter((it) => it);
} catch (err) {
console.error('Failed to read chapters from file', err);
return [];
}
}
export async function html5ify({ filePath, outPath, video, audio, onProgress }) { export async function html5ify({ filePath, outPath, video, audio, onProgress }) {
console.log('Making HTML5 friendly version', { filePath, outPath, video, audio }); console.log('Making HTML5 friendly version', { filePath, outPath, video, audio });

@ -4,6 +4,8 @@ import i18n from 'i18next';
import randomColor from './random-color'; import randomColor from './random-color';
import { parseYouTube } from './edlFormats';
const path = window.require('path'); const path = window.require('path');
const fs = window.require('fs-extra'); const fs = window.require('fs-extra');
const open = window.require('open'); const open = window.require('open');
@ -197,6 +199,26 @@ export async function askForHtml5ifySpeed(allowedOptions) {
return value; return value;
} }
export async function askForYouTubeInput() {
const example = i18n.t('YouTube video description\n00:00 Intro\n00:01 Chapter 2\n00:00:02.123 Chapter 3');
const { value } = await Swal.fire({
title: i18n.t('Import text chapters / YouTube'),
input: 'textarea',
inputPlaceholder: example,
text: i18n.t('Paste or type a YouTube chapters description or textual chapter description'),
showCancelButton: true,
inputValidator: (v) => {
if (v) {
const edl = parseYouTube(v);
if (edl.length > 0) return undefined;
}
return i18n.t('Please input a valid format.');
},
});
return parseYouTube(value);
}
export const isMasBuild = window.process.mas; export const isMasBuild = window.process.mas;
export const isStoreBuild = isMasBuild || window.process.windowsStore; export const isStoreBuild = isMasBuild || window.process.windowsStore;
@ -210,3 +232,33 @@ export async function askForOutDir(defaultPath) {
}); });
return (filePaths && filePaths.length === 1) ? filePaths[0] : undefined; return (filePaths && filePaths.length === 1) ? filePaths[0] : undefined;
} }
export async function askForFileOpenAction() {
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!'),
});
return value;
}
export async function askForImportChapters() {
const { value } = await Swal.fire({
icon: 'question',
text: i18n.t('This file has embedded chapters. Do you want to import the chapters as cut-segments?'),
showCancelButton: true,
cancelButtonText: i18n.t('Ignore chapters'),
confirmButtonText: i18n.t('Import chapters'),
});
return value;
}

@ -3203,6 +3203,11 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
chardet@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/chardet/-/chardet-1.3.0.tgz#a56ed2d9e4517a7128721340a0cb9a10a8fac238"
integrity sha512-cyTQGGptIjIT+CMGT5J/0l9c6Fb+565GCFjjeUTKxUO7w3oR+FcNCMEKTn5xtVKaLFmladN7QF68IiQsv5Fbdw==
chokidar@^2.0.2, chokidar@^2.1.8: chokidar@^2.0.2, chokidar@^2.1.8:
version "2.1.8" version "2.1.8"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917"
@ -4110,6 +4115,13 @@ csv-stringify@^5.3.6:
resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-5.3.6.tgz#2655e2e1c01b97b3963bccbc9407b8fb876dc589" resolved "https://registry.yarnpkg.com/csv-stringify/-/csv-stringify-5.3.6.tgz#2655e2e1c01b97b3963bccbc9407b8fb876dc589"
integrity sha512-kPcRbMvo5NLLD71TAqW5K+g9kbM2HpIZJLAzm73Du8U+5TXmDp9YtXKCBLyxEh0q3Jbg8QhNFBz3b5VJzjZ/jw== integrity sha512-kPcRbMvo5NLLD71TAqW5K+g9kbM2HpIZJLAzm73Du8U+5TXmDp9YtXKCBLyxEh0q3Jbg8QhNFBz3b5VJzjZ/jw==
cue-parser@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/cue-parser/-/cue-parser-0.3.0.tgz#813f4d47329b14bba998c6abdb7ee3f49147722f"
integrity sha512-dT9aE78B10akUWxt5SYhsyrGbIpoO67ELOk4DzIpwfhQNjauS9gxZ2SoOnwAfVUr2HAxFRH/8gH/8Umn4DYRow==
dependencies:
chardet "^1.0.0"
cyclist@^1.0.1: cyclist@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
@ -9748,6 +9760,11 @@ pify@^4.0.1:
resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231" resolved "https://registry.yarnpkg.com/pify/-/pify-4.0.1.tgz#4b2cd25c50d598735c50292224fd8c6df41e3231"
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
pify@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-5.0.0.tgz#1f5eca3f5e87ebec28cc6d54a0e4aaf00acc127f"
integrity sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==
pinkie-promise@^2.0.0, pinkie-promise@^2.0.1: pinkie-promise@^2.0.0, pinkie-promise@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa" resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"

Loading…
Cancel
Save