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

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

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

@ -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';
@ -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'));
@ -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'));
@ -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]);
@ -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'));
@ -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), []);
@ -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();
@ -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);
@ -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;
@ -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() {
@ -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}
@ -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);

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

@ -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 stringify from 'csv-stringify';
import i18n from 'i18next';
import fastXmlParser from 'fast-xml-parser';
import csvStringify from 'csv-stringify';
import pify from 'pify';
const fs = window.require('fs-extra');
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'));
import { parseCuesheet, parseXmeml, parseCsv } from './edlFormats';
const mapped = rows
.map(([start, end, name]) => ({
start: start === '' ? undefined : parseFloat(start, 10),
end: end === '' ? undefined : parseFloat(end, 10),
name,
}));
const fs = window.require('fs-extra');
const cueParser = window.require('cue-parser');
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'));
}
const csvStringifyAsync = pify(csvStringify);
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) {
const xml = fastXmlParser.parse(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 }));
return parseXmeml(await fs.readFile(path, 'utf-8'));
}
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);
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);
}

@ -344,6 +344,28 @@ export async function getDuration(filePath) {
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 }) {
console.log('Making HTML5 friendly version', { filePath, outPath, video, audio });

@ -4,6 +4,8 @@ import i18n from 'i18next';
import randomColor from './random-color';
import { parseYouTube } from './edlFormats';
const path = window.require('path');
const fs = window.require('fs-extra');
const open = window.require('open');
@ -197,6 +199,26 @@ export async function askForHtml5ifySpeed(allowedOptions) {
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 isStoreBuild = isMasBuild || window.process.windowsStore;
@ -210,3 +232,33 @@ export async function askForOutDir(defaultPath) {
});
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"
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:
version "2.1.8"
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"
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:
version "1.0.1"
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"
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:
version "2.0.1"
resolved "https://registry.yarnpkg.com/pinkie-promise/-/pinkie-promise-2.0.1.tgz#2135d6dfa7a358c069ac9b178776288228450ffa"

Loading…
Cancel
Save