diff --git a/README.md b/README.md index 125ccde4..2682e6b8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # LosslessCut 🎥 [![Travis](https://img.shields.io/travis/mifi/lossless-cut.svg)]() -Simple, cross platform video editor for lossless trimming / cutting of videos. Great for rough processing of large video files taken from a video camera, GoPro, drone, etc. Lets you quickly extract the good parts from your videos. It doesn't do any decoding / encoding and is therefore extremely fast and has no quality loss. Also allows for taking JPEG snapshots of the video at the selected time. This app uses the awesome ffmpeg🙏 for doing the grunt work. ffmpeg is not included and must be installed separately. +Simple, cross platform video editor for lossless trimming / cutting of videos. Great for rough processing of large video files taken from a video camera, GoPro, drone, etc. Lets you quickly extract the good parts from your videos. It doesn't do any decoding / encoding and is therefore extremely fast and has no quality loss. Also allows for taking JPEG snapshots of the video at the selected time. This app uses the awesome ffmpeg🙏 for doing the grunt work. ffmpeg is not included and must be installed separately. Also supports lossless cutting in the most common audio formats. ![Demo](demo.gif) @@ -38,7 +38,7 @@ The original video files will not be modified. Instead it creates a lossless exp ## Development building / running -This app is made using Electron. [electron-compile](https://github.com/electron/electron-compile) is used for development. Make sure you have at least node v4 with npm 3. +This app is made using Electron. Make sure you have at least node v4 with npm 3. ``` git clone https://github.com/mifi/lossless-cut.git cd lossless-cut @@ -46,6 +46,11 @@ npm install ``` ### Running +In one terminal: +``` +npm run watch +``` +Then: ``` npm start ``` @@ -59,14 +64,12 @@ npm run package ## TODO / ideas - About menu - icon -- Visual feedback on button presses -- ffprobe show keyframes -- ffprobe format -- improve ffmpeg error handling -- Slow scrub with modifier key -- show frame number - Bundle ffmpeg -- support for loading other formats by streaming through ffmpeg? +- Visual feedback on button presses +- support for previewing other formats by streaming through ffmpeg? +- Slow scrub with modifier key +- show frame number (approx?) +- ffprobe show keyframes - cutting out the commercials in a video file while saving the rest to a single file? ## Links diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 06df9a01..cc16618d 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -1,25 +1,74 @@ const execa = require('execa'); const bluebird = require('bluebird'); const which = bluebird.promisify(require('which')); +const path = require('path'); +const util = require('./util'); const Configstore = require('configstore'); const configstore = new Configstore('lossless-cut', { ffmpegPath: '' }); -module.exports.cut = (filePath, cutFrom, cutTo, outFile) => { +function showFfmpegFail(err) { + alert('Failed to run ffmpeg, make sure you have it installed and in available in your PATH or set its path (from the file menu)'); + console.error(err.stack); +} + +function getFfmpegPath() { + return which('ffmpeg') + .catch(() => configstore.get('ffmpegPath')); +} + +function cut(filePath, format, cutFrom, cutTo) { + const ext = path.extname(filePath) || format; + const outFileAppend = `${util.formatDuration(cutFrom)}-${util.formatDuration(cutTo)}`; + const outFile = `${filePath}-${outFileAppend}.${ext}`; + console.log('Cutting from', cutFrom, 'to', cutTo); - return which('ffmpeg') - .catch(() => configstore.get('ffmpegPath')) - .then(ffmpegPath => execa(ffmpegPath, [ - '-i', filePath, '-y', '-vcodec', 'copy', '-acodec', 'copy', '-ss', cutFrom, '-t', cutTo - cutFrom, outFile, - ])) + const ffmpegArgs = [ + '-i', filePath, '-y', '-vcodec', 'copy', '-acodec', 'copy', + '-ss', cutFrom, '-t', cutTo - cutFrom, + '-f', format, + outFile, + ]; + + console.log('ffmpeg', ffmpegArgs.join(' ')); + + return getFfmpegPath() + .then(ffmpegPath => execa(ffmpegPath, ffmpegArgs)) .then((result) => { console.log(result.stdout); }) .catch((err) => { - console.error(err.stack); - alert(`Failed to run ffmpeg, make sure you have it installed and in available in your PATH or its path configured in ${configstore.path}`); + if (err.code === 1) { + alert('Whoops! ffmpeg was unable to cut this video. It may be of an unknown format or codec combination'); + return; + } + showFfmpegFail(err); }); +} + +function getFormats(filePath) { + console.log('getFormat', filePath); + + return getFfmpegPath() + .then(ffmpegPath => path.join(path.dirname(ffmpegPath), 'ffprobe')) + .then(ffprobePath => execa(ffprobePath, [ + '-of', 'json', '-show_format', '-i', filePath, + ])) + .then((result) => { + const formatsStr = JSON.parse(result.stdout).format.format_name; + console.log('formats', formatsStr); + const formats = formatsStr.split(','); + return formats; + }); +} + +// '-of', 'json', '-select_streams', 'v', '-show_frames', filePath, + +module.exports = { + cut, + getFormats, + showFfmpegFail, }; diff --git a/src/index.js b/src/index.js index 97bb9c95..1c78e0d2 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,10 @@ const BrowserWindow = electron.BrowserWindow; const dialog = electron.dialog; const configstore = new Configstore('lossless-cut'); +// http://stackoverflow.com/questions/39362292/how-do-i-set-node-env-production-on-electron-app-when-packaged-with-electron-pac +const isProd = process.execPath.search('electron-prebuilt') === -1; +if (isProd) process.env.NODE_ENV = 'production'; + // Keep a global reference of the window object, if you don't, the window will // be closed automatically when the JavaScript object is garbage collected. let mainWindow; diff --git a/src/renderer.jsx b/src/renderer.jsx index 20dc5757..e06b42ca 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -1,7 +1,6 @@ const electron = require('electron'); // eslint-disable-line const $ = require('jquery'); const keyboardJs = require('keyboardjs'); -const ffmpeg = require('./ffmpeg'); const _ = require('lodash'); const captureFrame = require('capture-frame'); const fs = require('fs'); @@ -11,19 +10,8 @@ const React = require('react'); const ReactDOM = require('react-dom'); const classnames = require('classnames'); -function formatDuration(_seconds) { - const seconds = _seconds || 0; - const minutes = seconds / 60; - const hours = minutes / 60; - - const hoursPadded = _.padStart(Math.floor(hours), 2, '0'); - const minutesPadded = _.padStart(Math.floor(minutes % 60), 2, '0'); - const secondsPadded = _.padStart(Math.floor(seconds) % 60, 2, '0'); - const msPadded = _.padStart(Math.floor((seconds - Math.floor(seconds)) * 1000), 3, '0'); - - // Be nice to filenames and use . - return `${hoursPadded}.${minutesPadded}.${secondsPadded}.${msPadded}`; -} +const ffmpeg = require('./ffmpeg'); +const util = require('./util'); function getVideo() { return $('#player video')[0]; @@ -63,6 +51,7 @@ class App extends React.Component { duration: undefined, cutStartTime: 0, cutEndTime: undefined, + fileFormat: undefined, }; this.state = _.cloneDeep(defaultState); @@ -76,7 +65,19 @@ class App extends React.Component { const load = (filePath) => { resetState(); - this.setState({ filePath }); + + ffmpeg.getFormats(filePath) + .then((formats) => { + if (formats.length < 1) return alert('Unsupported file'); + return this.setState({ filePath, fileFormat: formats[0] }); + }) + .catch((err) => { + if (err.code === 1) { + alert('Unsupported file'); + return; + } + ffmpeg.showFfmpegFail(err); + }); }; electron.ipcRenderer.on('file-opened', (event, message) => { @@ -180,17 +181,14 @@ class App extends React.Component { } this.setState({ working: true }); - const ext = 'mp4'; - const outFileAppend = `${formatDuration(cutStartTime)}-${formatDuration(cutEndTime)}`; - const outPath = `${filePath}-${outFileAppend}.${ext}`; - return ffmpeg.cut(filePath, cutStartTime, cutEndTime, outPath) + return ffmpeg.cut(filePath, this.state.fileFormat, cutStartTime, cutEndTime) .finally(() => this.setState({ working: false })); } capture() { if (!this.state.filePath) return; const buf = captureFrame(getVideo(), 'jpg'); - const outPath = `${this.state.filePath}-${formatDuration(this.state.currentTime)}.jpg`; + const outPath = `${this.state.filePath}-${util.formatDuration(this.state.currentTime)}.jpg`; fs.writeFile(outPath, buf, (err) => { if (err) alert(err); }); @@ -223,16 +221,16 @@ class App extends React.Component { }} >