From e913982dc41f9e4319810dbfd71aad5b8fb01d01 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Sun, 16 Feb 2020 12:33:38 +0800 Subject: [PATCH] Many UI improvements #189 - support arbitrary stream selection #214 - implement mute playbakc #125 --- .babelrc | 2 +- .eslintrc | 4 +- package.json | 6 +- src/HelpSheet.jsx | 24 ++- src/StreamsSelector.jsx | 82 ++++++++ src/TimelineSeg.jsx | 14 +- src/ffmpeg.js | 45 ++-- src/main.css | 10 +- src/renderer.jsx | 446 +++++++++++++++++++++------------------- src/util.js | 16 +- yarn.lock | 383 ++++++++++++++++++++++++++++++++-- 11 files changed, 764 insertions(+), 268 deletions(-) create mode 100644 src/StreamsSelector.jsx 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:

+ + + + + + + + + + + + + + + {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 ( + + + + + + + + + + + + ); + })} + +
+ + TypeTagCodecDurationFramesBitrateData
{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']), + '-c', 'copy', - '-vcodec', 'copy', - '-scodec', '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 - - - -
- 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 (
+
+ + )} + > + + + +
+ + {renderOutFmt({ width: 60 })} + + + + + + + + +
+ {!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 */} -
+
{/* 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 &&
} + {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)} - /> - ))} + + {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)}
+
+ 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} - - - - - - - + onClick={addCutSegment} + /> - - - +
-
- +
+
+ {isRotationSet && rotationStr} + +
- + {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"