diff --git a/.babelrc b/.babelrc
index 90192ed5..d0ee1e15 100644
--- a/.babelrc
+++ b/.babelrc
@@ -1,7 +1,7 @@
{
"presets": [
["env", {
- "targets": { "electron": "1.8" }
+ "targets": { "electron": "8.0" }
}],
"react"
],
diff --git a/.eslintrc b/.eslintrc
index 489331cd..f86ece64 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -13,14 +13,16 @@
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "error",
+ "react/jsx-fragments": 0,
"no-console": 0,
"react/destructuring-assignment": 0,
"react/forbid-prop-types": [1, { "forbid": ["any"] }],
"jsx-a11y/click-events-have-key-events": 0,
+ "jsx-a11y/interactive-supports-focus": 0,
"react/jsx-one-expression-per-line": 0,
"object-curly-newline": 0,
"arrow-parens": 0,
"jsx-a11y/control-has-associated-label": 0,
- "react/prop-types": 0
+ "react/prop-types": 0,
}
}
diff --git a/package.json b/package.json
index 7d0d0c58..415064f7 100644
--- a/package.json
+++ b/package.json
@@ -55,10 +55,12 @@
"color": "^3.1.0",
"electron-default-menu": "^1.0.0",
"electron-is-dev": "^0.1.2",
+ "evergreen-ui": "^4.23.0",
"execa": "^0.5.0",
"ffmpeg-static": "3",
"ffprobe-static": "^3.0.0",
"file-type": "^12.4.0",
+ "framer-motion": "^1.8.4",
"fs-extra": "^8.1.0",
"github-api": "^3.2.2",
"hammerjs": "^2.0.8",
@@ -86,10 +88,10 @@
},
"browserslist": {
"production": [
- "electron 7.0"
+ "electron 8.0"
],
"development": [
- "electron 7.0"
+ "electron 8.0"
]
},
"build": {
diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx
index 47de8744..5ba07bfb 100644
--- a/src/HelpSheet.jsx
+++ b/src/HelpSheet.jsx
@@ -1,10 +1,16 @@
import React from 'react';
import { IoIosCloseCircleOutline } from 'react-icons/io';
+import { motion, AnimatePresence } from 'framer-motion';
-const HelpSheet = ({ visible, onTogglePress, renderSettings }) => {
- if (visible) {
- return (
-
+const HelpSheet = ({ visible, onTogglePress, renderSettings }) => (
+
+ {visible && (
+
Keyboard shortcuts
@@ -29,11 +35,9 @@ const HelpSheet = ({ visible, onTogglePress, renderSettings }) => {
Settings
{renderSettings()}
-
- );
- }
-
- return null;
-};
+
+ )}
+
+);
export default HelpSheet;
diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx
new file mode 100644
index 00000000..17bef3e1
--- /dev/null
+++ b/src/StreamsSelector.jsx
@@ -0,0 +1,82 @@
+import React, { memo } from 'react';
+
+import { FaVideo, FaVideoSlash, FaFileExport, FaVolumeUp, FaVolumeMute, FaBan } from 'react-icons/fa';
+import { GoFileBinary } from 'react-icons/go';
+import { MdSubtitles } from 'react-icons/md';
+
+const { formatDuration } = require('./util');
+const { getStreamFps } = require('./ffmpeg');
+
+
+const StreamsSelector = memo(({
+ streams, copyStreamIds, toggleCopyStreamId, onExtractAllStreamsPress,
+}) => {
+ if (!streams) return null;
+
+ return (
+
+
Click to select which tracks to keep:
+
+
+
+
+
+
+ Type
+ Tag
+ Codec
+ Duration
+ Frames
+ Bitrate
+ Data
+
+
+
+ {streams.map((stream) => {
+ const bitrate = parseInt(stream.bit_rate, 10);
+ const duration = parseInt(stream.duration, 10);
+
+ function onToggle() {
+ toggleCopyStreamId(stream.index);
+ }
+
+ const copyStream = copyStreamIds[stream.index];
+
+ let Icon;
+ if (stream.codec_type === 'audio') {
+ Icon = copyStream ? FaVolumeUp : FaVolumeMute;
+ } else if (stream.codec_type === 'video') {
+ Icon = copyStream ? FaVideo : FaVideoSlash;
+ } else if (stream.codec_type === 'subtitle') {
+ Icon = copyStream ? MdSubtitles : FaBan;
+ } else {
+ Icon = copyStream ? GoFileBinary : FaBan;
+ }
+
+ const streamFps = getStreamFps(stream);
+
+ return (
+
+
+ {stream.index}
+ {stream.codec_type}
+ {stream.codec_tag !== '0x0000' && stream.codec_tag_string}
+ {stream.codec_name}
+ {!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}
+ {stream.nb_frames}
+ {!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}
+ {stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(1)}fps`}
+
+ );
+ })}
+
+
+
+
+ Export each track as individual files
+
+
+ );
+});
+
+export default StreamsSelector;
diff --git a/src/TimelineSeg.jsx b/src/TimelineSeg.jsx
index 175e018a..6f00e444 100644
--- a/src/TimelineSeg.jsx
+++ b/src/TimelineSeg.jsx
@@ -1,4 +1,5 @@
-import React, { Fragment } from 'react';
+import React from 'react';
+import { motion } from 'framer-motion';
const { formatDuration } = require('./util');
@@ -44,12 +45,17 @@ const TimelineSeg = ({
return (
// eslint-disable-next-line react/jsx-fragments
-
+
{cutStartTime !== undefined && (
)}
{isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && (
-
)}
-
+
);
};
diff --git a/src/ffmpeg.js b/src/ffmpeg.js
index ae38c17a..39d28eee 100644
--- a/src/ffmpeg.js
+++ b/src/ffmpeg.js
@@ -64,7 +64,7 @@ function handleProgress(process, cutDuration, onProgress) {
async function cut({
filePath, format, cutFrom, cutTo, cutToApparent, videoDuration, rotation,
- includeAllStreams, onProgress, stripAudio, keyframeCut, outPath,
+ onProgress, copyStreamIds, keyframeCut, outPath,
}) {
console.log('Cutting from', cutFrom, 'to', cutToApparent);
@@ -90,13 +90,12 @@ async function cut({
const ffmpegArgs = [
...inputCutArgs,
- ...(stripAudio ? ['-an'] : ['-acodec', 'copy']),
-
- '-vcodec', 'copy',
- '-scodec', 'copy',
+ '-c', 'copy',
- ...(includeAllStreams ? ['-map', '0'] : []),
+ ...flatMap(Object.keys(copyStreamIds).filter(index => copyStreamIds[index]), index => ['-map', `0:${index}`]),
'-map_metadata', '0',
+ // https://video.stackexchange.com/questions/23741/how-to-prevent-ffmpeg-from-dropping-metadata
+ '-movflags', 'use_metadata_tags',
// See https://github.com/mifi/lossless-cut/issues/170
'-ignore_unknown',
@@ -121,7 +120,7 @@ async function cut({
async function cutMultiple({
customOutDir, filePath, format, segments: segmentsUnsorted, videoDuration, rotation,
- includeAllStreams, onProgress, stripAudio, keyframeCut,
+ onProgress, keyframeCut, copyStreamIds,
}) {
const segments = sortBy(segmentsUnsorted, 'cutFrom');
const singleProgresses = {};
@@ -148,8 +147,7 @@ async function cutMultiple({
format,
videoDuration,
rotation,
- includeAllStreams,
- stripAudio,
+ copyStreamIds,
keyframeCut,
cutFrom,
cutTo,
@@ -218,7 +216,7 @@ async function html5ifyDummy(filePath, outPath) {
await transferTimestamps(filePath, outPath);
}
-async function mergeFiles({ paths, outPath, includeAllStreams }) {
+async function mergeFiles({ paths, outPath }) {
console.log('Merging files', { paths }, 'to', outPath);
// https://blog.yo1.dog/fix-for-ffmpeg-protocol-not-on-whitelist-error-for-urls/
@@ -226,7 +224,7 @@ async function mergeFiles({ paths, outPath, includeAllStreams }) {
'-f', 'concat', '-safe', '0', '-protocol_whitelist', 'file,pipe', '-i', '-',
'-c', 'copy',
- ...(includeAllStreams ? ['-map', '0'] : []),
+ '-map', '0',
'-map_metadata', '0',
// See https://github.com/mifi/lossless-cut/issues/170
@@ -251,17 +249,17 @@ async function mergeFiles({ paths, outPath, includeAllStreams }) {
console.log(result.stdout);
}
-async function mergeAnyFiles({ customOutDir, paths, includeAllStreams }) {
+async function mergeAnyFiles({ customOutDir, paths }) {
const firstPath = paths[0];
const ext = path.extname(firstPath);
const outPath = getOutPath(customOutDir, firstPath, `merged${ext}`);
- return mergeFiles({ paths, outPath, includeAllStreams });
+ return mergeFiles({ paths, outPath });
}
-async function autoMergeSegments({ customOutDir, sourceFile, segmentPaths, includeAllStreams }) {
+async function autoMergeSegments({ customOutDir, sourceFile, segmentPaths }) {
const ext = path.extname(sourceFile);
const outPath = getOutPath(customOutDir, sourceFile, `cut-merged-${new Date().getTime()}${ext}`);
- await mergeFiles({ paths: segmentPaths, outPath, includeAllStreams });
+ await mergeFiles({ paths: segmentPaths, outPath });
await pMap(segmentPaths, trash, { concurrency: 5 });
}
@@ -403,6 +401,21 @@ async function renderFrame(timestamp, filePath, rotation) {
return url;
}
+// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079
+const defaultProcessedCodecTypes = [
+ 'video',
+ 'audio',
+ 'subtitle',
+];
+
+function getStreamFps(stream) {
+ const match = typeof stream.avg_frame_rate === 'string' && stream.avg_frame_rate.match(/^([0-9]+)\/([0-9]+)$/);
+ if (stream.codec_type === 'video' && match) {
+ return parseInt(match[1], 10) / parseInt(match[2], 10);
+ }
+ return undefined;
+}
+
module.exports = {
cutMultiple,
@@ -414,4 +427,6 @@ module.exports = {
extractAllStreams,
renderFrame,
getAllStreams,
+ defaultProcessedCodecTypes,
+ getStreamFps,
};
diff --git a/src/main.css b/src/main.css
index 87fd4d46..3d504c84 100644
--- a/src/main.css
+++ b/src/main.css
@@ -52,19 +52,11 @@ input, button, textarea, :focus {
padding: .3em;
}
-.left-menu {
- position: absolute;
- left: 0;
- bottom: 0;
- padding: .3em;
-}
-
.controls-wrapper {
position: absolute;
left: 0;
right: 0;
bottom: 0;
- height: 5.75rem;
background: #6b6b6b;
text-align: center;
}
@@ -114,7 +106,7 @@ input, button, textarea, :focus {
}
.help-sheet h1 {
- font-size: 1em;
+ font-size: 1.2em;
text-transform: uppercase;
}
diff --git a/src/renderer.jsx b/src/renderer.jsx
index c50c31c2..0a26f9e3 100644
--- a/src/renderer.jsx
+++ b/src/renderer.jsx
@@ -1,15 +1,24 @@
import React, { memo, useEffect, useState, useCallback, useRef } from 'react';
-import { IoIosHelpCircle } from 'react-icons/io';
+import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io';
+import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
+import { MdRotate90DegreesCcw } from 'react-icons/md';
+import { FiScissors } from 'react-icons/fi';
+import { AnimatePresence } from 'framer-motion';
+
+import { Popover, Button } from 'evergreen-ui';
+import fromPairs from 'lodash/fromPairs';
+import clamp from 'lodash/clamp';
+import clone from 'lodash/clone';
import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg';
+import StreamsSelector from './StreamsSelector';
import { loadMifiLink } from './mifi';
+
+const isDev = require('electron-is-dev');
const electron = require('electron'); // eslint-disable-line
const Mousetrap = require('mousetrap');
-const round = require('lodash/round');
-const clamp = require('lodash/clamp');
-const clone = require('lodash/clone');
const Hammer = require('react-hammerjs').default;
const path = require('path');
const trash = require('trash');
@@ -25,6 +34,8 @@ const { showMergeDialog, showOpenAndMergeDialog } = require('./merge/merge');
const captureFrame = require('./capture-frame');
const ffmpeg = require('./ffmpeg');
+const { defaultProcessedCodecTypes, getStreamFps } = ffmpeg;
+
const {
getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
@@ -80,13 +91,12 @@ const App = memo(() => {
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [rotationPreviewRequested, setRotationPreviewRequested] = useState(false);
const [filePath, setFilePath] = useState('');
- const [playbackRate, setPlaybackRate] = useState(1);
const [detectedFps, setDetectedFps] = useState();
const [streams, setStreams] = useState([]);
+ const [copyStreamIds, setCopyStreamIds] = useState({});
+ const [muted, setMuted] = useState(false);
// Global state
- const [stripAudio, setStripAudio] = useState(false);
- const [includeAllStreams, setIncludeAllStreams] = useState(true);
const [captureFormat, setCaptureFormat] = useState('jpeg');
const [customOutDir, setCustomOutDir] = useState();
const [keyframeCut, setKeyframeCut] = useState(true);
@@ -98,6 +108,11 @@ const App = memo(() => {
const videoRef = useRef();
const timelineWrapperRef = useRef();
+
+ function toggleCopyStreamId(index) {
+ setCopyStreamIds(v => ({ ...v, [index]: !v[index] }));
+ }
+
function seekAbs(val) {
const video = videoRef.current;
if (val == null || Number.isNaN(val)) return;
@@ -140,8 +155,10 @@ const App = memo(() => {
setStartTimeOffset(0);
setRotationPreviewRequested(false);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
- setPlaybackRate(1);
setDetectedFps();
+ setStreams([]);
+ setCopyStreamIds({});
+ setMuted(false);
}, []);
useEffect(() => () => {
@@ -290,7 +307,7 @@ const App = memo(() => {
// console.log('merge', paths);
await ffmpeg.mergeAnyFiles({
- customOutDir, paths, includeAllStreams,
+ customOutDir, paths,
});
} catch (err) {
errorToast('Failed to merge files. Make sure they are all of the exact same format and codecs');
@@ -298,14 +315,23 @@ const App = memo(() => {
} finally {
setWorking(false);
}
- }, [customOutDir, includeAllStreams]);
+ }, [customOutDir]);
const toggleCaptureFormat = () => setCaptureFormat(f => (f === 'png' ? 'jpeg' : 'png'));
- const toggleIncludeAllStreams = () => setIncludeAllStreams(v => !v);
- const toggleStripAudio = () => setStripAudio(sa => !sa);
const toggleKeyframeCut = () => setKeyframeCut(val => !val);
const toggleAutoMerge = () => setAutoMerge(val => !val);
+ const copyAnyAudioTrack = streams.some(stream => copyStreamIds[stream.index] && stream.codec_type === 'audio');
+ function toggleStripAudio() {
+ setCopyStreamIds((old) => {
+ const newCopyStreamIds = { ...old };
+ streams.forEach((stream) => {
+ if (stream.codec_type === 'audio') newCopyStreamIds[stream.index] = !copyAnyAudioTrack;
+ });
+ return newCopyStreamIds;
+ });
+ }
+
const removeCutSegment = useCallback(() => {
if (cutSegments.length < 2) return;
@@ -327,8 +353,6 @@ const App = memo(() => {
seekAbs((relX / target.offsetWidth) * (duration || 0));
}
- const onPlaybackRateChange = () => setPlaybackRate(videoRef.current.playbackRate);
-
const playCommand = useCallback(() => {
const video = videoRef.current;
if (playing) return video.pause();
@@ -389,8 +413,7 @@ const App = memo(() => {
format: fileFormat,
videoDuration: duration,
rotation: effectiveRotation,
- includeAllStreams,
- stripAudio,
+ copyStreamIds,
keyframeCut,
segments,
onProgress: setCutProgress,
@@ -403,9 +426,10 @@ const App = memo(() => {
customOutDir,
sourceFile: filePath,
segmentPaths: outFiles,
- includeAllStreams,
});
}
+
+ toast.fire({ timer: 10000, type: 'success', title: `Cut completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` });
} catch (err) {
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
@@ -417,15 +441,18 @@ const App = memo(() => {
showFfmpegFail(err);
} finally {
- toast.fire({ timer: 10000, type: 'success', title: `Cut completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` });
setWorking(false);
}
}, [
effectiveRotation, getApparentCutStartTime, getApparentCutEndTime, getCutEndTime,
getCutStartTime, isCutRangeValid, working, cutSegments, duration, filePath, keyframeCut,
- autoMerge, customOutDir, fileFormat, includeAllStreams, stripAudio,
+ autoMerge, customOutDir, fileFormat, copyStreamIds,
]);
+ function showUnsupportedFileMessage() {
+ toast.fire({ timer: 10000, type: 'warning', title: 'This video is not natively supported', text: 'This means that there is no audio in the preview and it has low quality. The final cut operation will however be lossless and contains audio!' });
+ }
+
// TODO use ffmpeg to capture frame
const capture = useCallback(async () => {
if (!filePath) return;
@@ -459,12 +486,16 @@ const App = memo(() => {
await ffmpeg.html5ifyDummy(fp, html5ifiedDummyPathDummy);
setDummyVideoPath(html5ifiedDummyPathDummy);
setHtml5FriendlyPath();
+ showUnsupportedFileMessage();
}, [customOutDir]);
const checkExistingHtml5FriendlyFile = useCallback(async (fp, speed) => {
const existing = getHtml5ifiedPath(fp, speed);
const ret = existing && await exists(existing);
- if (ret) setHtml5FriendlyPath(existing);
+ if (ret) {
+ setHtml5FriendlyPath(existing);
+ showUnsupportedFileMessage();
+ }
return ret;
}, [getHtml5ifiedPath]);
@@ -487,14 +518,17 @@ const App = memo(() => {
}
const { streams: streamsNew } = await ffmpeg.getAllStreams(fp);
- // console.log('streams', streams);
+ console.log('streams', streamsNew);
setStreams(streamsNew);
+ setCopyStreamIds(fromPairs(streamsNew.map((stream) => [
+ stream.index, defaultProcessedCodecTypes.includes(stream.codec_type),
+ ])));
+
streamsNew.find((stream) => {
- const match = typeof stream.avg_frame_rate === 'string' && stream.avg_frame_rate.match(/^([0-9]+)\/([0-9]+)$/);
- if (stream.codec_type === 'video' && match) {
- const fps = parseInt(match[1], 10) / parseInt(match[2], 10);
- setDetectedFps(fps);
+ const streamFps = getStreamFps(stream);
+ if (streamFps != null) {
+ setDetectedFps(streamFps);
return true;
}
return false;
@@ -507,6 +541,7 @@ const App = memo(() => {
if (html5FriendlyPathRequested) {
setHtml5FriendlyPath(html5FriendlyPathRequested);
+ showUnsupportedFileMessage();
} else if (
!(await checkExistingHtml5FriendlyFile(fp, 'slow-audio') || await checkExistingHtml5FriendlyFile(fp, 'slow') || await checkExistingHtml5FriendlyFile(fp, 'fast'))
&& !doesPlayerSupportFile(streamsNew)
@@ -572,6 +607,25 @@ const App = memo(() => {
electron.ipcRenderer.send('renderer-ready');
}, []);
+ const extractAllStreams = useCallback(async () => {
+ if (!filePath) return;
+
+ try {
+ setWorking(true);
+ await ffmpeg.extractAllStreams({ customOutDir, filePath });
+ toast.fire({ type: 'success', title: `All streams can be found as separate files at: ${getOutDir(customOutDir, filePath)}` });
+ } catch (err) {
+ errorToast('Failed to extract all streams');
+ console.error('Failed to extract all streams', err);
+ } finally {
+ setWorking(false);
+ }
+ }, [customOutDir, filePath]);
+
+ function onExtractAllStreamsPress() {
+ extractAllStreams();
+ }
+
useEffect(() => {
function fileOpened(event, filePaths) {
if (!filePaths || filePaths.length !== 1) return;
@@ -622,20 +676,6 @@ const App = memo(() => {
setStartTimeOffset(newStartTimeOffset);
}
- async function extractAllStreams() {
- if (!filePath) return;
-
- try {
- setWorking(true);
- await ffmpeg.extractAllStreams({ customOutDir, filePath });
- } catch (err) {
- errorToast('Failed to extract all streams');
- console.error('Failed to extract all streams', err);
- } finally {
- setWorking(false);
- }
- }
-
electron.ipcRenderer.on('file-opened', fileOpened);
electron.ipcRenderer.on('close-file', closeFile);
electron.ipcRenderer.on('html5ify', html5ify);
@@ -653,7 +693,7 @@ const App = memo(() => {
};
}, [
load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath,
- createDummyVideo, resetState,
+ createDummyVideo, resetState, extractAllStreams,
]);
const onDrop = useCallback((ev) => {
@@ -722,9 +762,6 @@ const App = memo(() => {
const jumpCutButtonStyle = {
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
};
- const infoSpanStyle = {
- background: 'rgba(255, 255, 255, 0.4)', padding: '.1em .4em', margin: '0 3px', fontSize: 13, borderRadius: '.3em',
- };
function renderOutFmt({ width } = {}) {
return (
@@ -778,7 +815,7 @@ const App = memo(() => {
type="button"
onClick={toggleAutoMerge}
>
- {autoMerge ? 'Auto merge segments to one file (am)' : 'Export separate segments (nm)'}
+ {autoMerge ? 'Auto merge segments to one file' : 'Export separate segments'}
@@ -790,27 +827,11 @@ const App = memo(() => {
type="button"
onClick={toggleKeyframeCut}
>
- {keyframeCut ? 'Nearest keyframe cut (kc) - will cut at the nearest keyframe' : 'Normal cut (nc) - cut accurate position but could leave an empty portion'}
+ {keyframeCut ? 'Nearest keyframe cut - will cut at the nearest keyframe' : 'Normal cut - cut accurate position but could leave an empty portion'}
-
- Include treams
-
-
- {includeAllStreams ? 'include all streams (audio, video, subtitle, data) (all)' : 'include only primary streams (1 audio and 1 video stream only) (ps)'}
-
-
-
- Note that some streams like subtitles and data are not possible to cut and will therefore be transferred as is.
-
-
-
-
Delete audio?
@@ -820,7 +841,7 @@ const App = memo(() => {
type="button"
onClick={toggleStripAudio}
>
- {stripAudio ? 'Delete all audio tracks' : 'Keep all audio tracks'}
+ {!copyAnyAudioTrack ? 'Delete all audio tracks' : 'Keep audio tracks'}
@@ -832,9 +853,9 @@ const App = memo(() => {
type="button"
onClick={setOutputDir}
>
- {outputDir ? 'Custom output directory (cd)' : 'Output files to same directory as input (id)'}
+ {customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
- {outputDir}
+ {customOutDir}
@@ -855,12 +876,68 @@ const App = memo(() => {
loadMifiLink().then(setMifiLink);
}, []);
+ useEffect(() => {
+ // Testing:
+ if (isDev) load('/Users/mifi/Downloads/inp.MOV');
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const topBarHeight = '2rem';
+ const bottomBarHeight = '6rem';
+
+ const VolumeIcon = muted ? FaVolumeMute : FaVolumeUp;
+
return (
+
+
+ )}
+ >
+
Tracks
+
+
+
+
+ {renderOutFmt({ width: 60 })}
+
+
+ {autoMerge ? 'Merge cuts' : 'Separate cuts'}
+
+
+
+ {keyframeCut ? 'Keyframe cut' : 'Normal cut'}
+
+
+
+ {copyAnyAudioTrack ? 'Keep audio' : 'Delete audio'}
+
+
+
+
+
{!filePath && (
-
+
DROP VIDEO
-
PRESS H FOR HELP
{mifiLink && mifiLink.loadUrl && (
@@ -874,7 +951,7 @@ const App = memo(() => {
{working && (
@@ -886,22 +963,13 @@ const App = memo(() => {
)}
- {rotationPreviewRequested && (
-
- Lossless rotation preview
-
- )}
-
{/* eslint-disable jsx-a11y/media-has-caption */}
-
+
onPlayingChange(true)}
onPause={() => onPlayingChange(false)}
onDurationChange={e => setDuration(e.target.duration)}
@@ -920,13 +988,30 @@ const App = memo(() => {
{/* eslint-enable jsx-a11y/media-has-caption */}
- {(html5FriendlyPath || dummyVideoPath) && (
-
- This video is not natively supported, so there is no audio in the preview and it is of low quality.
The final cut operation will however be lossless and contain audio!
+ {rotationPreviewRequested && (
+
+ Lossless rotation preview
+
+ )}
+
+ {filePath && (
+
+ setMuted(v => !v)}
+ />
)}
-
+
{
>
- {currentTimePos !== undefined &&
}
-
- {cutSegments.map((seg, i) => (
-
setCurrentSeg(currentSegNew)}
- isActive={i === currentSeg}
- isCutRangeValid={isCutRangeValid(i)}
- duration={durationSafe}
- cutStartTime={getCutStartTime(i)}
- cutEndTime={getCutEndTime(i)}
- apparentCutStart={getApparentCutStartTime(i)}
- apparentCutEnd={getApparentCutEndTime(i)}
- />
- ))}
-
- {formatTimecode(offsetCurrentTime)}
+ {currentTimePos !== undefined &&
}
+
+
+ {cutSegments.map((seg, i) => (
+ setCurrentSeg(currentSegNew)}
+ isActive={i === currentSeg}
+ isCutRangeValid={isCutRangeValid(i)}
+ duration={durationSafe}
+ cutStartTime={getCutStartTime(i)}
+ cutEndTime={getCutEndTime(i)}
+ apparentCutStart={getApparentCutStartTime(i)}
+ apparentCutEnd={getApparentCutEndTime(i)}
+ />
+ ))}
+
+
+
+ setTimecodeShowFrames(v => !v)}>
+ {formatTimecode(offsetCurrentTime)}
+
+
@@ -1019,135 +1110,78 @@ const App = memo(() => {
-
- 1 ? 'Export all segments' : 'Export selection'}
- className="button fa fa-scissors"
role="button"
- tabIndex="0"
- onClick={cutClick}
/>
-
-
-
- {renderOutFmt({ width: 30 })}
-
-
- {round(playbackRate, 1) || 1}
-
-
- setTimecodeShowFrames(v => !v))}
- >
- {detectedFps ? round(detectedFps, 1) || 1 : '-'}
-
-
- removeCutSegment())}
- >
- d
- {currentSeg + 1}
-
-
-
+ addCutSegment())}
- >
- c+
-
-
-
- {autoMerge ? 'am' : 'nm'}
-
+ onClick={addCutSegment}
+ />
-
+
-
-
- {keyframeCut ? 'kc' : 'nc'}
-
-
-
- {includeAllStreams ? 'all' : 'ps'}
-
-
-
- {stripAudio ? 'da' : 'ka'}
-
-
-
- {isRotationSet ? rotationStr : '-°'}
-
+
+
+ {isRotationSet && rotationStr}
+
+
-
- {outputDir ? 'cd' : 'id'}
-
+ {renderCaptureFormatButton()}
-
- {renderCaptureFormatButton()}
+
+ 1 ? 'Export all segments' : 'Export selection'}
+ />
+ Export
+
=0.10.0"
+
isstream@~0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a"
@@ -3642,6 +3862,16 @@ locate-path@^5.0.0:
dependencies:
p-locate "^4.1.0"
+lodash.debounce@^4.0.8:
+ version "4.0.8"
+ resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
+ integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168=
+
+lodash.mapvalues@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/lodash.mapvalues/-/lodash.mapvalues-4.6.0.tgz#1bafa5005de9dd6f4f26668c30ca37230cc9689c"
+ integrity sha1-G6+lAF3p3W9PJmaMMMo3IwzJaJw=
+
lodash@^4.17.10, lodash@^4.17.13, lodash@^4.17.4:
version "4.17.13"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.13.tgz#0bdc3a6adc873d2f4e0c4bac285df91b64fc7b93"
@@ -3896,6 +4126,14 @@ nice-try@^1.0.4:
resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"
integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==
+node-fetch@^1.0.1:
+ version "1.7.3"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-1.7.3.tgz#980f6f72d85211a5347c6b2bc18c5b84c3eb47ef"
+ integrity sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==
+ dependencies:
+ encoding "^0.1.11"
+ is-stream "^1.0.1"
+
node-pre-gyp@^0.6.39:
version "0.6.39"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.6.39.tgz#c00e96860b23c0e1420ac7befc5044e1d78d8649"
@@ -4371,6 +4609,18 @@ pn@^1.0.0:
resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
integrity sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==
+popmotion@9.0.0-beta-8:
+ version "9.0.0-beta-8"
+ resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.0.0-beta-8.tgz#f5a709f11737734e84f2a6b73f9bcf25ee30c388"
+ integrity sha512-6eQzqursPvnP7ePvdfPeY4wFHmS3OLzNP8rJRvmfFfEIfpFqrQgLsM50Gd9AOvGKJtYJOFknNG+dsnzCpgIdAA==
+ dependencies:
+ "@popmotion/easing" "^1.0.1"
+ "@popmotion/popcorn" "^0.4.2"
+ framesync "^4.0.4"
+ hey-listen "^1.0.8"
+ style-value-types "^3.1.6"
+ tslib "^1.10.0"
+
prelude-ls@~1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
@@ -4411,15 +4661,14 @@ progress@^2.0.0:
resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
integrity sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=
-prop-types@^15.5.7, prop-types@^15.6.2:
- version "15.6.2"
- resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
- integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
+promise@^7.1.1:
+ version "7.3.1"
+ resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf"
+ integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==
dependencies:
- loose-envify "^1.3.1"
- object-assign "^4.1.1"
+ asap "~2.0.3"
-prop-types@^15.7.2:
+prop-types@^15.5.10, prop-types@^15.6.0, prop-types@^15.7.2:
version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@@ -4428,6 +4677,14 @@ prop-types@^15.7.2:
object-assign "^4.1.1"
react-is "^16.8.1"
+prop-types@^15.5.7, prop-types@^15.6.2:
+ version "15.6.2"
+ resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
+ integrity sha512-3pboPvLiWD7dkI3qf3KbUe6hKFKa52w+AE0VCqECtf+QHAKgOL37tTaNCnuX1nAAQ4ZhyP+kYVKf8rLmJ/feDQ==
+ dependencies:
+ loose-envify "^1.3.1"
+ object-assign "^4.1.1"
+
proto-list@~1.2.1:
version "1.2.4"
resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849"
@@ -4509,6 +4766,16 @@ react-dom@^16.12.0:
prop-types "^15.6.2"
scheduler "^0.18.0"
+react-event-listener@^0.5.1:
+ version "0.5.10"
+ resolved "https://registry.yarnpkg.com/react-event-listener/-/react-event-listener-0.5.10.tgz#378403c555fe616f312891507a742ecbbe2c90de"
+ integrity sha512-YZklRszh9hq3WP3bdNLjFwJcTCVe7qyTf5+LWNaHfZQaZrptsefDK2B5HHpOsEEaMHvjllUPr0+qIFVTSsurow==
+ dependencies:
+ "@babel/runtime" "7.0.0-beta.42"
+ fbjs "^0.8.16"
+ prop-types "^15.6.0"
+ warning "^3.0.0"
+
react-fast-compare@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-2.0.4.tgz#e84b4d455b0fec113e0402c329352715196f81f9"
@@ -4528,11 +4795,26 @@ react-icons@^3.9.0:
dependencies:
camelcase "^5.0.0"
-react-is@^16.8.1:
+react-is@^16.8.1, react-is@^16.9.0:
version "16.12.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c"
integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q==
+react-lifecycles-compat@^3.0.4:
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
+ integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
+
+react-scrollbar-size@^2.0.2:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/react-scrollbar-size/-/react-scrollbar-size-2.1.0.tgz#105e797135cab92b1f9e16f00071db7f29f80754"
+ integrity sha512-9dDUJvk7S48r0TRKjlKJ9e/LkLLYgc9LdQR6W21I8ZqtSrEsedPOoMji4nU3DHy7fx2l8YMScJS/N7qiloYzXQ==
+ dependencies:
+ babel-runtime "^6.26.0"
+ prop-types "^15.6.0"
+ react-event-listener "^0.5.1"
+ stifle "^1.0.2"
+
react-sortable-hoc@^1.5.3:
version "1.5.3"
resolved "https://registry.yarnpkg.com/react-sortable-hoc/-/react-sortable-hoc-1.5.3.tgz#99482ee6435e898cae3cd4632958bb9c7cc5a948"
@@ -4542,6 +4824,23 @@ react-sortable-hoc@^1.5.3:
invariant "^2.2.4"
prop-types "^15.5.7"
+react-tiny-virtual-list@^2.1.4:
+ version "2.2.0"
+ resolved "https://registry.yarnpkg.com/react-tiny-virtual-list/-/react-tiny-virtual-list-2.2.0.tgz#eafb6fcf764e4ed41150ff9752cdaad8b35edf4a"
+ integrity sha512-MDiy2xyqfvkWrRiQNdHFdm36lfxmcLLKuYnUqcf9xIubML85cmYCgzBJrDsLNZ3uJQ5LEHH9BnxGKKSm8+C0Bw==
+ dependencies:
+ prop-types "^15.5.7"
+
+react-transition-group@^2.5.0:
+ version "2.9.0"
+ resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-2.9.0.tgz#df9cdb025796211151a436c69a8f3b97b5b07c8d"
+ integrity sha512-+HzNTCHpeQyl4MJ/bdE0u6XRMe9+XG/+aL4mCxVN4DnPBQ0/5bfHWPDuOZUzYdMj94daZaZdCCc1Dzt9R/xSSg==
+ dependencies:
+ dom-helpers "^3.4.0"
+ loose-envify "^1.4.0"
+ prop-types "^15.6.2"
+ react-lifecycles-compat "^3.0.4"
+
react-use@^13.24.0:
version "13.24.0"
resolved "https://registry.yarnpkg.com/react-use/-/react-use-13.24.0.tgz#f4574e26cfaaad65e3f04c0d5ff80c1836546236"
@@ -4702,7 +5001,7 @@ regenerator-runtime@^0.10.5:
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz#336c3efc1220adcedda2c9fab67b5a7955a33658"
integrity sha1-M2w+/BIgrc7dosn6tntaeVWjNlg=
-regenerator-runtime@^0.11.0:
+regenerator-runtime@^0.11.0, regenerator-runtime@^0.11.1:
version "0.11.1"
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9"
integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==
@@ -5070,6 +5369,11 @@ set-immediate-shim@^1.0.1:
resolved "https://registry.yarnpkg.com/set-immediate-shim/-/set-immediate-shim-1.0.1.tgz#4b2b1b27eb808a9f8dcc481a58e5e56f599f3f61"
integrity sha1-SysbJ+uAip+NzEgaWOXlb1mfP2E=
+setimmediate@^1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285"
+ integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=
+
shebang-command@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea"
@@ -5234,6 +5538,11 @@ stat-mode@^1.0.0:
resolved "https://registry.yarnpkg.com/stat-mode/-/stat-mode-1.0.0.tgz#68b55cb61ea639ff57136f36b216a291800d1465"
integrity sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==
+stifle@^1.0.2:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/stifle/-/stifle-1.1.1.tgz#4e4c565f19dcf9a6efa3a7379a70c42179edb8d6"
+ integrity sha512-INvON4DXLAWxpor+f0ZHnYQYXBqDXQRW1znLpf5/C/AWzJ0eQQAThfdqHQ5BDkiyywD67rQGvbE4LC+Aig6K/Q==
+
string-to-stream@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/string-to-stream/-/string-to-stream-1.1.1.tgz#aba78f73e70661b130ee3e1c0192be4fef6cb599"
@@ -5378,6 +5687,25 @@ strong-data-uri@^1.0.5:
dependencies:
truncate "^2.0.1"
+style-value-types@^3.1.6, style-value-types@^3.1.7:
+ version "3.1.7"
+ resolved "https://registry.yarnpkg.com/style-value-types/-/style-value-types-3.1.7.tgz#3d7d3cf9cb9f9ee86c00e19ba65d6a181a0db33a"
+ integrity sha512-jPaG5HcAPs3vetSwOJozrBXxuHo9tjZVnbRyBjxqb00c2saIoeuBJc1/2MtvB8eRZy41u/BBDH0CpfzWixftKg==
+ dependencies:
+ hey-listen "^1.0.8"
+ tslib "^1.10.0"
+
+stylefire@^7.0.2:
+ version "7.0.2"
+ resolved "https://registry.yarnpkg.com/stylefire/-/stylefire-7.0.2.tgz#874a82dd2bcada39c13e75e0c67b70009e06f556"
+ integrity sha512-LFIBP6fIA+EMqLSvM4V6zLa+O/iAcHoNJVuXkkZ5G8+T+Pd3KaQLqgxrpkeo1bwWQHqzgab8U3V3gudO231fZA==
+ dependencies:
+ "@popmotion/popcorn" "^0.4.4"
+ framesync "^4.0.0"
+ hey-listen "^1.0.8"
+ style-value-types "^3.1.7"
+ tslib "^1.10.0"
+
stylis@3.5.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/stylis/-/stylis-3.5.0.tgz#016fa239663d77f868fef5b67cf201c4b7c701e1"
@@ -5490,11 +5818,16 @@ throttleit@^1.0.0:
resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=
-through@^2.3.6:
+through@^2.3.6, through@^2.3.8:
version "2.3.8"
resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5"
integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=
+tinycolor2@^1.4.1:
+ version "1.4.1"
+ resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.4.1.tgz#f4fad333447bc0b07d4dc8e9209d8f39a8ac77e8"
+ integrity sha1-9PrTM0R7wLB9TcjpIJ2POaisd+g=
+
tmp@^0.0.33:
version "0.0.33"
resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9"
@@ -5579,7 +5912,7 @@ tslib@^1.10.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==
-tslib@^1.9.0:
+tslib@^1.9.0, tslib@~1.9.0:
version "1.9.3"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286"
integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ==
@@ -5630,6 +5963,20 @@ typedarray@^0.0.6:
resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
+ua-parser-js@^0.7.18:
+ version "0.7.21"
+ resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.21.tgz#853cf9ce93f642f67174273cc34565ae6f308777"
+ integrity sha512-+O8/qh/Qj8CgC6eYBVBykMrNtp5Gebn4dlGD/kKXVkJNDwyrAwSIqwz8CDf+tsAIWVycKcku6gIXJ0qwx/ZXaQ==
+
+ui-box@^2.1.2:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ui-box/-/ui-box-2.1.3.tgz#f2ef9c549d8c60dfdd7fbdea36d7956b96a814fa"
+ integrity sha512-taaEYH+tKdTXkrv0hVPl6NCGf5XAo+a940+/czsnWR0JYtnS4THMXo7nmzQzD/4MzD9PKG721hlPgTwHQbrBMQ==
+ dependencies:
+ "@emotion/hash" "^0.7.1"
+ inline-style-prefixer "^5.0.4"
+ prop-types "^15.7.2"
+
uid-number@^0.0.6:
version "0.0.6"
resolved "https://registry.yarnpkg.com/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81"
@@ -5743,6 +6090,18 @@ verror@1.3.6:
dependencies:
extsprintf "1.0.2"
+warning@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
+ integrity sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=
+ dependencies:
+ loose-envify "^1.0.0"
+
+whatwg-fetch@>=0.10.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.0.0.tgz#fc804e458cc460009b1a2b966bc8817d2578aefb"
+ integrity sha512-9GSJUgz1D4MfyKU7KRqwOjXCXTqWdFNvEr7eUBYchQiVc744mqK/MzXPNR2WsPkmkOa4ywfg8C2n8h+13Bey1Q==
+
which-module@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/which-module/-/which-module-1.0.0.tgz#bba63ca861948994ff307736089e3b96026c2a4f"