diff --git a/.eslintrc b/.eslintrc index f86ece640a..ba8b6a6bcf 100644 --- a/.eslintrc +++ b/.eslintrc @@ -10,6 +10,7 @@ "browser": true }, "rules": { + "max-len": 0, "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "error", diff --git a/package.json b/package.json index 717d4548dd..79b064c295 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "mousetrap": "^1.6.1", "p-map": "^3.0.0", "p-queue": "^6.2.0", + "pretty-ms": "^6.0.0", "prop-types": "^15.6.2", "react": "^16.12.0", "react-dom": "^16.12.0", diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx index cc4328cebd..5a6d658f75 100644 --- a/src/HelpSheet.jsx +++ b/src/HelpSheet.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo } from 'react'; import { IoIosCloseCircleOutline } from 'react-icons/io'; import { FaClipboard } from 'react-icons/fa'; import { motion, AnimatePresence } from 'framer-motion'; @@ -9,9 +9,8 @@ const { clipboard } = require('electron'); const { toast } = require('./util'); -const HelpSheet = ({ - visible, onTogglePress, renderSettings, ffmpegCommandLog, sortedCutSegments, - formatTimecode, +const HelpSheet = memo(({ + visible, onTogglePress, renderSettings, ffmpegCommandLog, }) => ( {visible && ( @@ -30,8 +29,8 @@ const HelpSheet = ({
L Speed up video
Seek backward 1 sec
Seek forward 1 sec
-
. (period) Tiny seek forward (1/60 sec)
-
, (comma) Tiny seek backward (1/60 sec)
+
, Seek backward 1 frame
+
. Seek forward 1 frame
I Mark in / cut start point
O Mark out / cut end point
E Cut (export selection in the same directory)
@@ -56,16 +55,6 @@ const HelpSheet = ({ -

Segment list

- -
- {sortedCutSegments.map((seg) => ( -
- {formatTimecode(seg.start)} - {formatTimecode(seg.end)} {seg.name} -
- ))} -
-

Last ffmpeg commands

{ffmpegCommandLog.reverse().map((log) => ( @@ -77,6 +66,6 @@ const HelpSheet = ({ )} -); +)); export default HelpSheet; diff --git a/src/InverseCutSegment.jsx b/src/InverseCutSegment.jsx index 025a81bf5b..3344bb8163 100644 --- a/src/InverseCutSegment.jsx +++ b/src/InverseCutSegment.jsx @@ -3,6 +3,7 @@ import { motion } from 'framer-motion'; import { FaTrashAlt, FaSave } from 'react-icons/fa'; import { mySpring } from './animations'; +import { saveColor } from './colors'; const InverseCutSegment = ({ seg, duration, invertCutSegments }) => ( ( >
{invertCutSegments ? ( - + ) : ( )} diff --git a/src/SegmentList.jsx b/src/SegmentList.jsx new file mode 100644 index 0000000000..b2ac02127a --- /dev/null +++ b/src/SegmentList.jsx @@ -0,0 +1,69 @@ +import React, { memo, Fragment } from 'react'; +import prettyMs from 'pretty-ms'; +import { FaSave } from 'react-icons/fa'; +import { motion } from 'framer-motion'; + +import { saveColor } from './colors'; + +const SegmentList = memo(({ + formatTimecode, cutSegments, getFrameCount, getSegColors, onSegClick, + currentSegIndex, invertCutSegments, +}) => { + if (!cutSegments && invertCutSegments) { + return
Make sure you have no overlapping segments.
; + } + + if (!cutSegments || cutSegments.length === 0) { + return
No segments to export.
; + } + + return ( + +
Segments to export:
+ {cutSegments.map((seg, index) => { + const duration = seg.end - seg.start; + const durationMs = duration * 1000; + + const isActive = !invertCutSegments && currentSegIndex === index; + const uuid = seg.uuid || `${seg.start}`; + + function renderNumber() { + if (invertCutSegments) return ; + + const { + segBgColor, segBorderColor, + } = getSegColors(seg); + + return {index + 1}; + } + + return ( + !invertCutSegments && onSegClick(index)} + key={uuid} + positionTransition + style={{ originY: 0, margin: '5px 0', border: `1px solid rgba(255,255,255,${isActive ? 1 : 0.3})`, padding: 5, borderRadius: 5 }} + initial={{ scaleY: 0 }} + animate={{ scaleY: 1 }} + exit={{ scaleY: 0 }} + > +
+ {renderNumber()} + {formatTimecode(seg.start)} - {formatTimecode(seg.end)} +
+
+ Duration {prettyMs(durationMs)} +
+
+ ({Math.floor(durationMs)} ms, {getFrameCount(duration)} frames) +
+
{seg.name}
+
+ ); + })} +
+ ); +}); + +export default SegmentList; diff --git a/src/StreamsSelector.jsx b/src/StreamsSelector.jsx index 6ed940ed08..633569fe59 100644 --- a/src/StreamsSelector.jsx +++ b/src/StreamsSelector.jsx @@ -39,9 +39,11 @@ const Stream = memo(({ stream, onToggle, copyStream }) => { }); } + const onClick = () => onToggle && onToggle(stream.index); + return ( - onToggle && onToggle(stream.index)} /> + {stream.index} {stream.codec_type} {stream.codec_tag !== '0x0000' && stream.codec_tag_string} @@ -50,7 +52,7 @@ const Stream = memo(({ stream, onToggle, copyStream }) => { {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`} - onInfoClick(stream)} size={30} /> + onInfoClick(stream)} size={26} /> ); }); @@ -78,7 +80,7 @@ const StreamsSelector = memo(({ - + @@ -104,8 +106,9 @@ const StreamsSelector = memo(({ {Object.entries(externalFiles).map(([path, { streams }]) => ( + diff --git a/src/colors.js b/src/colors.js new file mode 100644 index 0000000000..52fdf325c6 --- /dev/null +++ b/src/colors.js @@ -0,0 +1,3 @@ +export const saveColor = 'hsl(158, 100%, 43%)'; +export const primaryColor = 'hsl(194, 78%, 47%)'; +export const controlsBackground = '#6b6b6b'; diff --git a/src/renderer.jsx b/src/renderer.jsx index ebb1192d57..9ec71a4a04 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useState, useCallback, useRef, Fragment } from 'react'; +import React, { memo, useEffect, useState, useCallback, useRef, Fragment, useMemo } from 'react'; import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io'; import { FaPlus, FaMinus, FaHandPointRight, FaHandPointLeft, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang, FaFileExport, FaTag } from 'react-icons/fa'; import { MdRotate90DegreesCcw, MdCallSplit, MdCallMerge } from 'react-icons/md'; @@ -17,10 +17,12 @@ import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; import HelpSheet from './HelpSheet'; +import SegmentList from './SegmentList'; import TimelineSeg from './TimelineSeg'; import InverseCutSegment from './InverseCutSegment'; import StreamsSelector from './StreamsSelector'; import { loadMifiLink } from './mifi'; +import { primaryColor, controlsBackground } from './colors'; import loadingLottie from './7077-magic-flow.json'; @@ -351,6 +353,11 @@ const App = memo(() => { seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined, }), [detectedFps, timecodeShowFrames]); + const getFrameCount = useCallback((sec) => { + if (detectedFps == null) return undefined; + return Math.floor(sec * detectedFps); + }, [detectedFps]); + const getCurrentTime = useCallback(() => ( playing ? playerTime : commandedTime), [commandedTime, playerTime, playing]); @@ -683,6 +690,9 @@ const App = memo(() => { } }, [filePath, html5FriendlyPath, resetState, working]); + const outSegments = useMemo(() => (invertCutSegments ? inverseCutSegments : apparentCutSegments), + [invertCutSegments, inverseCutSegments, apparentCutSegments]); + const cutClick = useCallback(async () => { if (working) { errorToast('I\'m busy'); @@ -699,19 +709,17 @@ const App = memo(() => { return; } - const segments = invertCutSegments ? inverseCutSegments : apparentCutSegments; - - if (!segments) { + if (!outSegments) { errorToast('No segments to export!'); return; } - const ffmpegSegments = segments.map((seg) => ({ + const ffmpegSegments = outSegments.map((seg) => ({ cutFrom: seg.start, cutTo: seg.end, })); - if (segments.length < 1) { + if (outSegments.length < 1) { errorToast('No segments to export'); return; } @@ -768,7 +776,7 @@ const App = memo(() => { setWorking(false); } }, [ - effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments, + effectiveRotation, outSegments, working, duration, filePath, keyframeCut, detectedFileFormat, autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy, exportExtraStreams, nonCopiedExtraStreams, outputDir, @@ -898,6 +906,8 @@ const App = memo(() => { const toggleHelp = () => setHelpVisible(val => !val); + const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length]); + useEffect(() => { Mousetrap.bind('space', () => playCommand()); Mousetrap.bind('k', () => playCommand()); @@ -905,6 +915,8 @@ const App = memo(() => { Mousetrap.bind('l', () => changePlaybackRate(1)); Mousetrap.bind('left', () => seekRel(-1)); Mousetrap.bind('right', () => seekRel(1)); + Mousetrap.bind('up', () => jumpSeg(-1)); + Mousetrap.bind('down', () => jumpSeg(1)); Mousetrap.bind('.', () => shortStep(1)); Mousetrap.bind(',', () => shortStep(-1)); Mousetrap.bind('c', () => capture()); @@ -923,6 +935,8 @@ const App = memo(() => { Mousetrap.unbind('l'); Mousetrap.unbind('left'); Mousetrap.unbind('right'); + Mousetrap.unbind('up'); + Mousetrap.unbind('down'); Mousetrap.unbind('.'); Mousetrap.unbind(','); Mousetrap.unbind('c'); @@ -936,7 +950,7 @@ const App = memo(() => { }; }, [ addCutSegment, capture, changePlaybackRate, cutClick, playCommand, removeCutSegment, - setCutEnd, setCutStart, seekRel, shortStep, deleteSource, + setCutEnd, setCutStart, seekRel, shortStep, deleteSource, jumpSeg, ]); useEffect(() => { @@ -1487,13 +1501,13 @@ const App = memo(() => { ); } - const primaryColor = 'hsl(194, 78%, 47%)'; + const rightBarWidth = 200; // TODO responsive const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit; return (
-
+
{filePath && ( { )} -
+
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
?Keep? Type Tag
removeFile(path)} /> - {path} removeFile(path)} /> + {path}