diff --git a/.eslintrc b/.eslintrc index e48dd403..012a46de 100644 --- a/.eslintrc +++ b/.eslintrc @@ -9,6 +9,7 @@ "no-console": 0, "react/destructuring-assignment": 0, "react/forbid-prop-types": [1, { "forbid": ["any"] }], + "jsx-a11y/click-events-have-key-events": 0, }, "plugins": [ "react" diff --git a/README.md b/README.md index bce9af0e..9a2a191e 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Simple and ultra fast cross platform tool for lossless trimming/cutting of video - Lossless cutting of common video and audio formats - Lossless merge of files (identical codecs) - Lossless extracting of all streams from a file (video, audio, subtitle, ++) +- Cut out multiple segments at the same time - Take full-resolution snapshots from videos in JPEG/PNG format - Manual input range of cutpoints - Can include more than 2 streams or remove audio track (optional) diff --git a/package.json b/package.json index a27a0345..650ad4eb 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "dependencies": { "bluebird": "^3.4.6", "classnames": "^2.2.5", + "color": "^3.1.0", "electron": "^2.0.9", "electron-default-menu": "^1.0.0", "electron-is-dev": "^0.1.2", @@ -77,6 +78,7 @@ "sweetalert2": "^8.0.1", "sweetalert2-react-content": "^1.0.1", "trash": "^4.3.0", + "uuid": "^3.3.2", "which": "^1.2.11" } } diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx index 9d623fc9..108f7f5b 100644 --- a/src/HelpSheet.jsx +++ b/src/HelpSheet.jsx @@ -20,6 +20,8 @@ const HelpSheet = ({ visible }) => {
  • O Mark out / cut end point
  • E Cut (export selection in the same directory)
  • C Capture snapshot (in the same directory)
  • +
  • + Add cut segment
  • +
  • BACKSPACE Remove current cut segment
  • ); diff --git a/src/TimelineSeg.jsx b/src/TimelineSeg.jsx new file mode 100644 index 00000000..5e6ed069 --- /dev/null +++ b/src/TimelineSeg.jsx @@ -0,0 +1,83 @@ +const React = require('react'); +const PropTypes = require('prop-types'); + +const TimelineSeg = ({ + isCutRangeValid, duration: durationRaw, cutStartTime, cutEndTime, apparentCutStart, + apparentCutEnd, isActive, segNum, onSegClick, color, +}) => { + const markerWidth = 4; + const duration = durationRaw || 1; + const cutSectionWidth = `calc(${((apparentCutEnd - apparentCutStart) / duration) * 100}% - ${markerWidth * 2}px)`; + + const startTimePos = `${(apparentCutStart / duration) * 100}%`; + const endTimePos = `${(apparentCutEnd / duration) * 100}%`; + const markerBorder = isActive ? `2px solid ${color.string()}` : undefined; + const markerBorderRadius = 5; + + const startMarkerStyle = { + background: color.alpha(0.5).string(), + width: markerWidth, + left: startTimePos, + borderLeft: markerBorder, + borderTopLeftRadius: markerBorderRadius, + borderBottomLeftRadius: markerBorderRadius, + }; + const endMarkerStyle = { + background: color.alpha(0.5).string(), + width: markerWidth, + marginLeft: -markerWidth, + left: endTimePos, + borderRight: markerBorder, + borderTopRightRadius: markerBorderRadius, + borderBottomRightRadius: markerBorderRadius, + }; + const cutSectionStyle = { + background: color.alpha(0.5).string(), + marginLeft: markerWidth, + left: startTimePos, + width: cutSectionWidth, + }; + + const onThisSegClick = () => onSegClick(segNum); + + return ( + + {cutStartTime !== undefined && ( +
    + )} + {isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && ( +
    + )} + {cutEndTime !== undefined && ( +
    + )} + + ); +}; + +TimelineSeg.propTypes = { + isCutRangeValid: PropTypes.bool.isRequired, + duration: PropTypes.number, + cutStartTime: PropTypes.number, + cutEndTime: PropTypes.number, + apparentCutStart: PropTypes.number.isRequired, + apparentCutEnd: PropTypes.number.isRequired, + isActive: PropTypes.bool.isRequired, + segNum: PropTypes.number.isRequired, + onSegClick: PropTypes.func.isRequired, + color: PropTypes.object.isRequired, +}; + +TimelineSeg.defaultProps = { + duration: undefined, + cutStartTime: undefined, + cutEndTime: undefined, +}; + +module.exports = TimelineSeg; diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 947c2817..80466812 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -5,6 +5,7 @@ const path = require('path'); const fileType = require('file-type'); const readChunk = require('read-chunk'); const flatMap = require('lodash/flatMap'); +const sum = require('lodash/sum'); const readline = require('readline'); const moment = require('moment'); const stringToStream = require('string-to-stream'); @@ -110,6 +111,39 @@ async function cut({ await transferTimestamps(filePath, outPath); } +async function cutMultiple({ + customOutDir, filePath, format, segments, videoDuration, rotation, + includeAllStreams, onProgress, stripAudio, keyframeCut, +}) { + const singleProgresses = {}; + function onSingleProgress(id, singleProgress) { + singleProgresses[id] = singleProgress; + return onProgress((sum(Object.values(singleProgresses)) / segments.length)); + } + + let i = 0; + // eslint-disable-next-line no-restricted-syntax + for (const { cutFrom, cutTo, cutToApparent } of segments) { + // eslint-disable-next-line no-await-in-loop + await cut({ + customOutDir, + filePath, + format, + videoDuration, + rotation, + includeAllStreams, + stripAudio, + keyframeCut, + cutFrom, + cutTo, + cutToApparent, + // eslint-disable-next-line no-loop-func + onProgress: progress => onSingleProgress(i, progress), + }); + i += 1; + } +} + async function html5ify(filePath, outPath, encodeVideo) { console.log('Making HTML5 friendly version', { filePath, outPath, encodeVideo }); @@ -274,7 +308,7 @@ async function extractAllStreams(filePath) { } module.exports = { - cut, + cutMultiple, getFormat, html5ify, mergeFiles, diff --git a/src/main.css b/src/main.css index 47dd40c8..8ef52db8 100644 --- a/src/main.css +++ b/src/main.css @@ -35,6 +35,7 @@ input, button, textarea, :focus { } .button { + border-radius: 3px; padding: .4em; vertical-align: middle; } @@ -102,12 +103,10 @@ input, button, textarea, :focus { .timeline-wrapper .cut-section { z-index: 1; - background-color: rgba(0, 255, 149, 0.5); } .timeline-wrapper .cut-time-marker { z-index: 2; box-sizing: border-box; - background: rgba(0, 255, 149, 0.5); } .timeline-wrapper .current-time { z-index: 3; diff --git a/src/random-color.js b/src/random-color.js new file mode 100644 index 00000000..66f64545 --- /dev/null +++ b/src/random-color.js @@ -0,0 +1,26 @@ +// https://github.com/mock-end/random-color/blob/master/index.js +/* eslint-disable */ + +const color = require('color'); + +var ratio = 0.618033988749895; +var hue = 0.65; + +module.exports = function (saturation, value) { + hue += ratio; + hue %= 1; + + if (typeof saturation !== 'number') { + saturation = 0.5; + } + + if (typeof value !== 'number') { + value = 0.95; + } + + return color({ + h: hue * 360, + s: saturation * 100, + v: value * 100, + }); +}; diff --git a/src/renderer.jsx b/src/renderer.jsx index 3078b40b..9da1a723 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -3,16 +3,19 @@ const $ = require('jquery'); const Mousetrap = require('mousetrap'); const round = require('lodash/round'); const clamp = require('lodash/clamp'); +const clone = require('lodash/clone'); const throttle = require('lodash/throttle'); const Hammer = require('react-hammerjs').default; const path = require('path'); const trash = require('trash'); +const uuid = require('uuid'); const React = require('react'); const ReactDOM = require('react-dom'); const classnames = require('classnames'); const HelpSheet = require('./HelpSheet'); +const TimelineSeg = require('./TimelineSeg'); const { showMergeDialog } = require('./merge/merge'); const captureFrame = require('./capture-frame'); @@ -21,7 +24,7 @@ const ffmpeg = require('./ffmpeg'); const { getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle, - promptTimeOffset, + promptTimeOffset, generateColor, } = require('./util'); const { dialog } = electron.remote; @@ -60,23 +63,31 @@ function withBlur(cb) { }; } +function createSegment({ start, end } = {}) { + return { + start, + end, + color: generateColor(), + uuid: uuid.v4(), + }; +} -const localState = { +const getInitialLocalState = () => ({ working: false, filePath: '', // Setting video src="" prevents memory leak in chromium html5FriendlyPath: undefined, playing: false, currentTime: undefined, duration: undefined, - cutStartTime: undefined, + cutSegments: [createSegment()], + currentSeg: 0, cutStartTimeManual: undefined, - cutEndTime: undefined, cutEndTimeManual: undefined, fileFormat: undefined, rotation: 360, cutProgress: undefined, startTimeOffset: 0, -}; +}); const globalState = { stripAudio: false, @@ -91,7 +102,7 @@ class App extends React.Component { super(props); this.state = { - ...localState, + ...getInitialLocalState(), ...globalState, }; @@ -220,6 +231,8 @@ class App extends React.Component { Mousetrap.bind('i', () => this.setCutStart()); Mousetrap.bind('o', () => this.setCutEnd()); Mousetrap.bind('h', () => this.toggleHelp()); + Mousetrap.bind('+', () => this.addCutSegment()); + Mousetrap.bind('backspace', () => this.removeCutSegment()); electron.ipcRenderer.send('renderer-ready'); } @@ -241,11 +254,13 @@ class App extends React.Component { } setCutStart = () => { - this.setState(({ currentTime }) => ({ cutStartTime: currentTime })); + const { currentTime } = this.state; + this.setCutTime('start', currentTime); } setCutEnd = () => { - this.setState(({ currentTime }) => ({ cutEndTime: currentTime })); + const { currentTime } = this.state; + this.setCutTime('end', currentTime); } setOutputDir = () => { @@ -274,13 +289,35 @@ class App extends React.Component { return `${this.getRotation()}°`; } - getApparentCutStartTime() { - if (this.state.cutStartTime !== undefined) return this.state.cutStartTime; + getCutSeg(i) { + const { currentSeg, cutSegments } = this.state; + return cutSegments[i !== undefined ? i : currentSeg]; + } + + getCutStartTime(i) { + return this.getCutSeg(i).start; + } + + getCutEndTime(i) { + return this.getCutSeg(i).end; + } + + setCutTime(type, time) { + const { currentSeg, cutSegments } = this.state; + const cloned = clone(cutSegments); + cloned[currentSeg][type] = time; + this.setState({ cutSegments: cloned }); + } + + getApparentCutStartTime(i) { + const cutStartTime = this.getCutStartTime(i); + if (cutStartTime !== undefined) return cutStartTime; return 0; } - getApparentCutEndTime() { - if (this.state.cutEndTime !== undefined) return this.state.cutEndTime; + getApparentCutEndTime(i) { + const cutEndTime = this.getCutEndTime(i); + if (cutEndTime !== undefined) return cutEndTime; if (this.state.duration !== undefined) return this.state.duration; return 0; // Haven't gotten duration yet } @@ -306,6 +343,41 @@ class App extends React.Component { toggleKeyframeCut = () => this.setState(({ keyframeCut }) => ({ keyframeCut: !keyframeCut })); + addCutSegment = () => { + const { cutSegments, currentTime, duration } = this.state; + + const cutStartTime = this.getCutStartTime(); + const cutEndTime = this.getCutEndTime(); + + if (cutStartTime === undefined && cutEndTime === undefined) return; + + const suggestedStart = currentTime; + const suggestedEnd = suggestedStart + 10; + + const cutSegmentsNew = [ + ...cutSegments, + createSegment({ + start: currentTime, + end: suggestedEnd <= duration ? suggestedEnd : undefined, + }), + ]; + + const currentSegNew = cutSegmentsNew.length - 1; + this.setState({ currentSeg: currentSegNew, cutSegments: cutSegmentsNew }); + } + + removeCutSegment = () => { + const { currentSeg, cutSegments } = this.state; + + if (cutSegments.length < 2) return; + + const cutSegmentsNew = [...cutSegments]; + cutSegmentsNew.splice(currentSeg, 1); + + const currentSegNew = Math.min(currentSeg, cutSegmentsNew.length - 1); + this.setState({ currentSeg: currentSegNew, cutSegments: cutSegmentsNew }); + } + jumpCutStart = () => { seekAbs(this.getApparentCutStartTime()); } @@ -350,37 +422,45 @@ class App extends React.Component { } cutClick = async () => { - if (this.state.working) { + const { + filePath, customOutDir, fileFormat, duration, includeAllStreams, + stripAudio, keyframeCut, working, cutSegments, + } = this.state; + + if (working) { errorToast('I\'m busy'); return; } - const { - cutEndTime, cutStartTime, filePath, customOutDir, fileFormat, duration, includeAllStreams, - stripAudio, keyframeCut, - } = this.state; - const rotation = this.isRotationSet() ? this.getRotation() : undefined; + const cutStartTime = this.getCutStartTime(); + const cutEndTime = this.getCutEndTime(); + if (!(this.isCutRangeValid() || cutEndTime === undefined || cutStartTime === undefined)) { errorToast('Start time must be before end time'); return; } - this.setState({ working: true }); try { - await ffmpeg.cut({ + this.setState({ working: true }); + + const segments = cutSegments.map((seg, i) => ({ + cutFrom: this.getApparentCutStartTime(i), + cutTo: this.getCutEndTime(i), + cutToApparent: this.getApparentCutEndTime(i), + })); + + await ffmpeg.cutMultiple({ customOutDir, filePath, format: fileFormat, - cutFrom: this.getApparentCutStartTime(), - cutTo: cutEndTime, - cutToApparent: this.getApparentCutEndTime(), videoDuration: duration, rotation, includeAllStreams, stripAudio, keyframeCut, + segments, onProgress: this.onCutProgress, }); } catch (err) { @@ -425,7 +505,7 @@ class App extends React.Component { const video = getVideo(); video.currentTime = 0; video.playbackRate = 1; - this.setState(localState); + this.setState(getInitialLocalState()); setFileNameTitle(); } @@ -434,8 +514,8 @@ class App extends React.Component { return this.state.rotation !== 360; } - isCutRangeValid() { - return this.getApparentCutStartTime() < this.getApparentCutEndTime(); + isCutRangeValid(i) { + return this.getApparentCutStartTime(i) < this.getApparentCutEndTime(i); } toggleHelp() { @@ -461,11 +541,9 @@ class App extends React.Component { return; } - const cutTimeKey = type === 'start' ? 'cutStartTime' : 'cutEndTime'; - this.setState(state => ({ - [cutTimeManualKey]: undefined, - [cutTimeKey]: time - state.startTimeOffset, - })); + this.setState({ [cutTimeManualKey]: undefined }); + + this.setCutTime(type, time - this.state.startTimeOffset); }; const cutTime = type === 'start' ? this.getApparentCutStartTime() : this.getApparentCutEndTime(); @@ -484,6 +562,18 @@ class App extends React.Component { } render() { + const { + working, filePath, duration: durationRaw, cutProgress, currentTime, playing, + fileFormat, playbackRate, keyframeCut, includeAllStreams, stripAudio, captureFormat, + helpVisible, currentSeg, cutSegments, + } = this.state; + + const duration = durationRaw || 1; + const currentTimePos = currentTime !== undefined && `${(currentTime / duration) * 100}%`; + + const segColor = this.getCutSeg().color; + const segBgColor = segColor.alpha(0.5).string(); + const jumpCutButtonStyle = { position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px', }; @@ -491,42 +581,6 @@ class App extends React.Component { background: 'rgba(255, 255, 255, 0.4)', padding: '.1em .4em', margin: '0 3px', fontSize: 13, borderRadius: '.3em', }; - const { - working, filePath, duration: durationRaw, cutProgress, currentTime, playing, - fileFormat, playbackRate, keyframeCut, includeAllStreams, stripAudio, captureFormat, - helpVisible, cutStartTime, cutEndTime, - } = this.state; - - const markerWidth = 4; - const apparentCutStart = this.getApparentCutStartTime(); - const apprentCutEnd = this.getApparentCutEndTime(); - const duration = durationRaw || 1; - const currentTimePos = currentTime !== undefined && `${(currentTime / duration) * 100}%`; - const cutSectionWidth = `calc(${((apprentCutEnd - apparentCutStart) / duration) * 100}% - ${markerWidth * 2}px)`; - - const isCutRangeValid = this.isCutRangeValid(); - - const startTimePos = `${(apparentCutStart / duration) * 100}%`; - const endTimePos = `${(apprentCutEnd / duration) * 100}%`; - const markerBorder = '2px solid rgb(0, 255, 149)'; - const markerBorderRadius = 5; - - const startMarkerStyle = { - width: markerWidth, - left: startTimePos, - borderLeft: markerBorder, - borderTopLeftRadius: markerBorderRadius, - borderBottomLeftRadius: markerBorderRadius, - }; - const endMarkerStyle = { - width: markerWidth, - marginLeft: -markerWidth, - left: endTimePos, - borderRight: markerBorder, - borderTopRightRadius: markerBorderRadius, - borderBottomRightRadius: markerBorderRadius, - }; - return (
    {!filePath && ( @@ -571,18 +625,21 @@ class App extends React.Component {
    {currentTimePos !== undefined &&
    } - {cutStartTime !== undefined &&
    } - {isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && ( -
    ( + this.setState({ currentSeg: currentSegNew })} + isActive={i === currentSeg} + isCutRangeValid={this.isCutRangeValid(i)} + duration={duration} + cutStartTime={this.getCutStartTime(i)} + cutEndTime={this.getCutEndTime(i)} + apparentCutStart={this.getApparentCutStartTime(i)} + apparentCutEnd={this.getApparentCutEndTime(i)} /> - )} - {cutEndTime !== undefined &&
    } + ))}
    {formatDuration(this.getOffsetCurrentTime())}
    @@ -591,7 +648,8 @@ class App extends React.Component {