diff --git a/src/InverseCutSegment.jsx b/src/InverseCutSegment.jsx new file mode 100644 index 00000000..9abcddc0 --- /dev/null +++ b/src/InverseCutSegment.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { FaTrashAlt, FaSave } from 'react-icons/fa'; + +import { mySpring } from './animations'; + +const InverseCutSegment = ({ seg, duration, invertCutSegments }) => ( + +
+ {invertCutSegments ? ( + + ) : ( + + )} +
+ +); + +export default InverseCutSegment; diff --git a/src/TimelineSeg.jsx b/src/TimelineSeg.jsx index 93fc34e0..a3be422e 100644 --- a/src/TimelineSeg.jsx +++ b/src/TimelineSeg.jsx @@ -1,68 +1,92 @@ import React from 'react'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; +import { FaTrashAlt } from 'react-icons/fa'; + +import { mySpring } from './animations'; const { formatDuration } = require('./util'); const TimelineSeg = ({ - duration, apparentCutStart, apparentCutEnd, isActive, segNum, - onSegClick, color, + duration, cutStart, cutEnd, isActive, segNum, + onSegClick, color, invertCutSegments, }) => { const markerWidth = 4; - const cutSectionWidth = `${Math.max(((apparentCutEnd - apparentCutStart) / duration) * 100, 1)}%`; + const cutSectionWidth = `${Math.max(((cutEnd - cutStart) / duration) * 100, 1)}%`; - const startTimePos = `${(apparentCutStart / duration) * 100}%`; - const markerBorder = isActive ? `2px solid ${color.lighten(0.5).string()}` : undefined; - const backgroundColor = isActive ? color.lighten(0.5).alpha(0.5).string() : color.alpha(0.5).string(); + const strongColor = color.lighten(0.5).string(); + const strongBgColor = color.lighten(0.5).alpha(0.5).string(); + const startTimePos = `${(cutStart / duration) * 100}%`; + const markerBorder = isActive ? `2px solid ${strongColor}` : undefined; + const backgroundColor = isActive ? strongBgColor : color.alpha(0.5).string(); const markerBorderRadius = 5; + const wrapperStyle = { + position: 'absolute', + top: 0, + bottom: 0, + left: startTimePos, + width: cutSectionWidth, + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + background: backgroundColor, + originX: 0, + borderRadius: markerBorderRadius, + }; + const startMarkerStyle = { + height: '100%', width: markerWidth, borderLeft: markerBorder, borderTopLeftRadius: markerBorderRadius, borderBottomLeftRadius: markerBorderRadius, }; const endMarkerStyle = { + height: '100%', width: markerWidth, borderRight: markerBorder, borderTopRightRadius: markerBorderRadius, borderBottomRightRadius: markerBorderRadius, }; - const wrapperStyle = { - position: 'absolute', - top: 0, - bottom: 0, - left: startTimePos, - width: cutSectionWidth, - display: 'flex', - background: backgroundColor, - originX: 0, - borderRadius: markerBorderRadius, - }; - const onThisSegClick = () => onSegClick(segNum); return ( cutStart ? formatDuration({ seconds: cutEnd - cutStart }) : undefined} > -
+
+ +
{segNum + 1}
+ + + {invertCutSegments && ( + + + + )} + + +
-
apparentCutStart ? formatDuration({ seconds: apparentCutEnd - apparentCutStart }) : undefined} - > - {segNum + 1} -
- {apparentCutEnd > apparentCutStart && ( -
+ {cutEnd > cutStart && ( +
)} ); diff --git a/src/animations.js b/src/animations.js new file mode 100644 index 00000000..d4d9ec12 --- /dev/null +++ b/src/animations.js @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export const mySpring = { type: 'spring', damping: 30, stiffness: 1000 }; diff --git a/src/renderer.jsx b/src/renderer.jsx index 453c6eb9..4dd1a46e 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -1,9 +1,10 @@ import React, { memo, useEffect, useState, useCallback, useRef } from 'react'; import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io'; -import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'; +import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; +import { GiYinYang } from 'react-icons/gi'; import { FiScissors } from 'react-icons/fi'; -import { AnimatePresence } from 'framer-motion'; +import { AnimatePresence, motion } from 'framer-motion'; import { Popover, Button } from 'evergreen-ui'; import fromPairs from 'lodash/fromPairs'; @@ -13,6 +14,7 @@ import sortBy from 'lodash/sortBy'; import HelpSheet from './HelpSheet'; import TimelineSeg from './TimelineSeg'; +import InverseCutSegment from './InverseCutSegment'; import StreamsSelector from './StreamsSelector'; import { loadMifiLink } from './mifi'; @@ -82,7 +84,7 @@ const App = memo(() => { const [currentTime, setCurrentTime] = useState(); const [duration, setDuration] = useState(); const [cutSegments, setCutSegments] = useState([createSegment()]); - const [currentSeg, setCurrentSeg] = useState(0); + const [currentSegIndex, setCurrentSegIndex] = useState(0); const [cutStartTimeManual, setCutStartTimeManual] = useState(); const [cutEndTimeManual, setCutEndTimeManual] = useState(); const [fileFormat, setFileFormat] = useState(); @@ -105,6 +107,7 @@ const App = memo(() => { const [helpVisible, setHelpVisible] = useState(false); const [timecodeShowFrames, setTimecodeShowFrames] = useState(false); const [mifiLink, setMifiLink] = useState(); + const [invertCutSegments, setInvertCutSegments] = useState(false); const videoRef = useRef(); const timelineWrapperRef = useRef(); @@ -152,7 +155,7 @@ const App = memo(() => { setWorking(false); setPlaying(false); setDuration(); - setCurrentSeg(0); + setCurrentSegIndex(0); setCutSegments([createSegment()]); setCutStartTimeManual(); setCutEndTimeManual(); @@ -167,6 +170,7 @@ const App = memo(() => { setStreams([]); setCopyStreamIds({}); setMuted(false); + setInvertCutSegments(false); }, []); useEffect(() => () => { @@ -175,10 +179,10 @@ const App = memo(() => { const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath); - // Because segments could have undefined start / end (meaning extend to start of timeline or end duration) + // Because segments could have undefined start / end + // (meaning extend to start of timeline or end duration) function getSegApparentStart(time) { - if (time !== undefined) return time; - return 0; + return time !== undefined ? time : 0; } const getSegApparentEnd = useCallback((time) => { @@ -199,6 +203,9 @@ const App = memo(() => { const haveInvalidSegs = invalidSegUuids.length > 0; + const currentCutSeg = cutSegments[currentSegIndex]; + const currentApparentCutSeg = apparentCutSegments[currentSegIndex]; + const inverseCutSegments = (() => { if (haveInvalidSegs) return undefined; if (apparentCutSegments.length < 1) return undefined; @@ -242,20 +249,17 @@ const App = memo(() => { const setCutTime = useCallback((type, time) => { const cloned = clone(cutSegments); - cloned[currentSeg][type] = time; + cloned[currentSegIndex][type] = time; setCutSegments(cloned); - }, [currentSeg, cutSegments]); + }, [currentSegIndex, cutSegments]); function formatTimecode(sec) { return formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined }); } - const getCutSeg = useCallback((i) => cutSegments[i !== undefined ? i : currentSeg], - [currentSeg, cutSegments]); - const addCutSegment = useCallback(() => { - const cutStartTime = getCutSeg().start; - const cutEndTime = getCutSeg().end; + const cutStartTime = currentCutSeg.start; + const cutEndTime = currentCutSeg.end; if (cutStartTime === undefined && cutEndTime === undefined) return; @@ -270,24 +274,24 @@ const App = memo(() => { }), ]; - const currentSegNew = cutSegmentsNew.length - 1; + const currentSegIndexNew = cutSegmentsNew.length - 1; setCutSegments(cutSegmentsNew); - setCurrentSeg(currentSegNew); + setCurrentSegIndex(currentSegIndexNew); }, [ - getCutSeg, cutSegments, currentTime, duration, + currentCutSeg, cutSegments, currentTime, duration, ]); const setCutStart = useCallback(() => { - const curSeg = getCutSeg(); // https://github.com/mifi/lossless-cut/issues/168 // If we are after the end of the last segment in the timeline, // add a new segment that starts at currentTime - if (curSeg.start != null && curSeg.end != null && currentTime > curSeg.end) { + if (currentCutSeg.start != null && currentCutSeg.end != null + && currentTime > currentCutSeg.end) { addCutSegment(); } else { setCutTime('start', currentTime); } - }, [setCutTime, currentTime, getCutSeg, addCutSegment]); + }, [setCutTime, currentTime, currentCutSeg, addCutSegment]); const setCutEnd = useCallback(() => setCutTime('end', currentTime), [setCutTime, currentTime]); @@ -356,13 +360,6 @@ const App = memo(() => { setRotationPreviewRequested(true); } - const getApparentCutStartTimeOrCurrent = useCallback((i) => getSegApparentStart(getCutSeg(i).start), [getCutSeg]); - - const getApparentCutEndTimeOrCurrent = useCallback((i) => { - const cutEndTime = getCutSeg(i).end; - return getSegApparentEnd(cutEndTime); - }, [getCutSeg, getSegApparentEnd]); - const offsetCurrentTime = (currentTime || 0) + startTimeOffset; const mergeFiles = useCallback(async (paths) => { @@ -400,15 +397,15 @@ const App = memo(() => { if (cutSegments.length < 2) return; const cutSegmentsNew = [...cutSegments]; - cutSegmentsNew.splice(currentSeg, 1); + cutSegmentsNew.splice(currentSegIndex, 1); - const currentSegNew = Math.min(currentSeg, cutSegmentsNew.length - 1); - setCurrentSeg(currentSegNew); + const currentSegIndexNew = Math.min(currentSegIndex, cutSegmentsNew.length - 1); + setCurrentSegIndex(currentSegIndexNew); setCutSegments(cutSegmentsNew); - }, [currentSeg, cutSegments]); + }, [currentSegIndex, cutSegments]); - const jumpCutStart = () => seekAbs(getApparentCutStartTimeOrCurrent()); - const jumpCutEnd = () => seekAbs(getApparentCutEndTimeOrCurrent()); + const jumpCutStart = () => seekAbs(currentApparentCutSeg.start); + const jumpCutEnd = () => seekAbs(currentApparentCutSeg.end); function handleTap(e) { const target = timelineWrapperRef.current; @@ -462,14 +459,21 @@ const App = memo(() => { return; } + const segments = invertCutSegments ? inverseCutSegments : apparentCutSegments; + + const ffmpegSegments = segments.map((seg) => ({ + cutFrom: seg.start, + cutTo: seg.end, + })); + + if (segments.length < 1) { + errorToast('No segments to export'); + return; + } + try { setWorking(true); - const segments = cutSegments.map((seg, i) => ({ - cutFrom: getApparentCutStartTimeOrCurrent(i), - cutTo: getApparentCutEndTimeOrCurrent(i), - })); - const outFiles = await ffmpeg.cutMultiple({ customOutDir, filePath, @@ -478,7 +482,7 @@ const App = memo(() => { rotation: effectiveRotation, copyStreamIds, keyframeCut, - segments, + segments: ffmpegSegments, onProgress: setCutProgress, }); @@ -507,8 +511,8 @@ const App = memo(() => { setWorking(false); } }, [ - effectiveRotation, getApparentCutStartTimeOrCurrent, getApparentCutEndTimeOrCurrent, - working, cutSegments, duration, filePath, keyframeCut, + effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments, + working, duration, filePath, keyframeCut, autoMerge, customOutDir, fileFormat, copyStreamIds, haveInvalidSegs, ]); @@ -801,7 +805,7 @@ const App = memo(() => { seekAbs(rel); }; - const cutTime = type === 'start' ? getApparentCutStartTimeOrCurrent() : getApparentCutEndTimeOrCurrent(); + const cutTime = type === 'start' ? currentApparentCutSeg.start : currentApparentCutSeg.end; return ( { const durationSafe = duration || 1; const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`; - const segColor = (getCutSeg() || {}).color; + const segColor = (currentCutSeg || {}).color; const segBgColor = segColor.alpha(0.5).string(); const jumpCutButtonStyle = { @@ -882,13 +886,13 @@ const App = memo(() => { type="button" onClick={toggleAutoMerge} > - {autoMerge ? 'Auto merge segments to one file' : 'Export separate segments'} + {autoMerge ? 'Auto merge segments to one file' : 'Export separate files'} - Cut mode + keyframe cut mode + + + Delete audio? @@ -953,6 +971,25 @@ const App = memo(() => { const VolumeIcon = muted ? FaVolumeMute : FaVolumeUp; + function renderInvertCutButton() { + const KeepOrDiscardIcon = invertCutSegments ? GiYinYang : FaYinYang; + + return ( +
+ + + setInvertCutSegments(v => !v))} + /> + + +
+ ); + } + return (
@@ -976,10 +1013,10 @@ const App = memo(() => {