diff --git a/.gitignore b/.gitignore index 9c0e10ce..947d083e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ node_modules npm-debug.log dist package +ffmpeg-tmp +icon-dist diff --git a/README.md b/README.md index 2682e6b8..d951e637 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,28 @@ # 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. Also supports lossless cutting in the most common audio formats. -![Demo](demo.gif) +![Screenshot](screenshot.jpg) -## Download +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. Also supports lossless cutting in the most common audio formats. +ffmpeg is now included in the app! 🎉 + +For an indication of supported formats / codecs, see https://www.chromium.org/audio-video + +![Demo](https://giant.gfycat.com/HighAcclaimedAnaconda.gif) ## Installing / running -- Install [ffmpeg](https://www.ffmpeg.org/download.html) - Download [latest LosslessCut from releases](https://github.com/mifi/lossless-cut/releases) -- Run app -- If ffmpeg is available in $PATH/%PATH% it will just work -- If not, a dialog will pop up to select ffmpeg executable path. +- Run LosslessCut app/exe ## Documentation +### Typical flow - Drag drop a video file into player to load or use ⌘/CTRL+O. -- Select the start and end time -- Press the scissors button to export a slice. -- Press the camera button to take a snapshot. +- Press SPACE to play/pause +- Select the cut start and end time +- Press the scissors button to export the slice +- Press the camera button to take a snapshot The original video files will not be modified. Instead it creates a lossless export in the same directory as the original file with from/to timestamps. Note that the cut is currently not precise around the cutpoints, so video before/after the nearest keyframe will be lost. EXIF data is preserved. @@ -38,7 +41,7 @@ The original video files will not be modified. Instead it creates a lossless exp ## Development building / running -This app is made using Electron. Make sure you have at least node v4 with npm 3. +This app is built using Electron. Make sure you have at least node v4 with npm 3. The app uses ffmpeg from PATH when developing. ``` git clone https://github.com/mifi/lossless-cut.git cd lossless-cut @@ -57,25 +60,12 @@ npm start ### Building package ``` +npm run download-ffmpeg +npm run extract-ffmpeg npm run build -npm run package +npm run icon-gen +npm run package # builds all platforms ``` -## TODO / ideas -- About menu -- icon -- Bundle 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 -- http://apple.stackexchange.com/questions/117306/what-options-are-available-to-losslessly-trim-mp4-m4v-video-on-10-8-or-above -- http://superuser.com/questions/554620/how-to-get-time-stamp-of-closest-keyframe-before-a-given-timestamp-with-ffmpeg/554679#554679 -- http://www.fame-ring.com/smart_cutter.html -- http://electron.atom.io/apps/ -- https://github.com/electron/electron/blob/master/docs/api/file-object.md -- https://github.com/electron/electron/issues/2538 +## Credits +- App icon made by [Dimi Kazak](http://www.flaticon.com/authors/dimi-kazak "Dimi Kazak") from [www.flaticon.com](http://www.flaticon.com "Flaticon") is licensed by [CC 3.0 BY](http://creativecommons.org/licenses/by/3.0/ "Creative Commons BY 3.0") diff --git a/TODO.md b/TODO.md new file mode 100644 index 00000000..f3de8d62 --- /dev/null +++ b/TODO.md @@ -0,0 +1,21 @@ +## TODO / ideas +- 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 (pprobe -of json -select_streams v -show_frames file.mp4) +- cutting out the commercials in a video file while saving the rest to a single file? +- With the GOP structure of h.264 you could run into some pretty nasty playback issues without re-encoding if you cut the wrong frames out. +- Shortcut Cmd+o also triggers o (cut end) +- implement electron app event "open-file" +- Travis github deploys https://docs.travis-ci.com/user/deployment +- react video ref="video" this.refs.video.play() +- A dedicated "Options" menu where the users can set a default output folder for captured frames and for cut videos will also be handy, now lossless-cut uses the input folder. + +## Links +- http://apple.stackexchange.com/questions/117306/what-options-are-available-to-losslessly-trim-mp4-m4v-video-on-10-8-or-above +- http://superuser.com/questions/554620/how-to-get-time-stamp-of-closest-keyframe-before-a-given-timestamp-with-ffmpeg/554679#554679 +- http://www.fame-ring.com/smart_cutter.html +- http://electron.atom.io/apps/ +- https://github.com/electron/electron/blob/master/docs/api/file-object.md +- https://github.com/electron/electron/issues/2538 diff --git a/demo.gif b/demo.gif deleted file mode 100644 index 46ac20c3..00000000 Binary files a/demo.gif and /dev/null differ diff --git a/package.json b/package.json index 60a0fdeb..4dfca843 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,18 @@ "start": "electron dist", "watch": "npm run build && babel src -d dist --copy-files -w", "build": "rm -rf dist && babel src -d dist --copy-files && ln -s ../node_modules dist/ && ln -s ../package.json ./dist/", - "package": "electron-packager dist LosslessCut --out=package --asar --overwrite --all --version 1.3.8", - "zip": "(cd package && for f in LosslessCut-*; do zip -r $f; done)", + "download-ffmpeg": "bash ./scripts/ffmpeg-dl/dl.sh", + "extract-ffmpeg": "bash ./scripts/ffmpeg-dl/extract.sh", + "copy-ffmpeg": "rm -rf dist/ffmpeg && mkdir dist/ffmpeg && cp ffmpeg-tmp/binaries/${PLATFORM}_${ARCH}/* dist/ffmpeg", + "package-single": "npm run copy-ffmpeg && electron-packager dist LosslessCut --out=package --asar.unpackDir=ffmpeg --overwrite --platform=${PLATFORM} --arch=${ARCH} --icon=icon-dist/${ICON}", + "package:darwin_x64": "PLATFORM=darwin ARCH=x64 ICON=app.icns npm run package-single", + "package:win32_ia32": "PLATFORM=win32 ARCH=ia32 ICON=app.ico npm run package-single", + "package:win32_x64": "PLATFORM=win32 ARCH=x64 ICON=app.ico npm run package-single", + "package:linux_ia32": "PLATFORM=linux ARCH=ia32 ICON=app.ico npm run package-single", + "package:linux_x64": "PLATFORM=linux ARCH=x64 ICON=app.ico npm run package-single", + "zip": "(cd package && rm -f LosslessCut-*.zip && for f in LosslessCut-*; do zip -r \"$f\".zip \"$f\"; done)", + "icon-gen": "icon-gen -i src/icon.svg -o ./icon-dist -r", + "package": "npm run package:darwin_x64 && npm run package:win32_ia32 && npm run package:win32_x64 && npm run package:linux_ia32 && npm run package:linux_x64 && npm run zip", "gifify": "gifify -p 405:299 -r 5@3 Untitled.mov-00.00.00.971-00.00.19.780.mp4", "lint": "eslint ." }, @@ -27,13 +37,13 @@ "eslint-config-airbnb": "^12.0.0", "eslint-plugin-import": "^1.16.0", "eslint-plugin-jsx-a11y": "^2.2.3", - "eslint-plugin-react": "^6.4.1" + "eslint-plugin-react": "^6.4.1", + "icon-gen": "git+https://github.com/mifi/npm-icon-gen.git#ca9a098482d09bd378328bc1810ec2846429d109" }, "dependencies": { "bluebird": "^3.4.6", "capture-frame": "^1.0.0", "classnames": "^2.2.5", - "configstore": "^2.1.0", "electron": "^1.4.5", "electron-default-menu": "^1.0.0", "execa": "^0.5.0", diff --git a/screenshot.jpg b/screenshot.jpg new file mode 100644 index 00000000..429b9971 Binary files /dev/null and b/screenshot.jpg differ diff --git a/scripts/ffmpeg-dl/dl.sh b/scripts/ffmpeg-dl/dl.sh new file mode 100755 index 00000000..0c6539e7 --- /dev/null +++ b/scripts/ffmpeg-dl/dl.sh @@ -0,0 +1,15 @@ +ffmpeg_linux_ia32=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-32bit-static.tar.xz +ffmpeg_linux_x64=https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-64bit-static.tar.xz +ffmpeg_darwin_x64=http://evermeet.cx/ffmpeg/ffmpeg-3.2.7z +ffmpeg_win32_ia32=https://ffmpeg.zeranoe.com/builds/win32/static/ffmpeg-3.1.5-win32-static.zip +ffmpeg_win32_x64=https://ffmpeg.zeranoe.com/builds/win64/static/ffmpeg-3.1.5-win64-static.zip +ffprobe_darwin_x64=http://evermeet.cx/ffmpeg/ffprobe-3.2.7z + +mkdir -p ffmpeg-tmp/archives && +(cd ffmpeg-tmp/archives && +wget -O ffmpeg_linux_ia32.tar.xz "${ffmpeg_linux_ia32}" && +wget -O ffmpeg_linux_x64.tar.xz "${ffmpeg_linux_x64}" && +wget -O ffmpeg_darwin_x64.7z "${ffmpeg_darwin_x64}" && +wget -O ffmpeg_win32_ia32.zip "${ffmpeg_win32_ia32}" && +wget -O ffmpeg_win32_x64.zip "${ffmpeg_win32_x64}" && +wget -O ffprobe_darwin_x64.7z "${ffprobe_darwin_x64}") diff --git a/scripts/ffmpeg-dl/extract.sh b/scripts/ffmpeg-dl/extract.sh new file mode 100755 index 00000000..4dc02a6c --- /dev/null +++ b/scripts/ffmpeg-dl/extract.sh @@ -0,0 +1,32 @@ +( + mkdir -p ffmpeg-tmp/extracted && + cd ffmpeg-tmp/extracted && + (mkdir -p linux_ia32 && cd linux_ia32 && + 7z x ../../archives/ffmpeg_linux_ia32.tar.xz && tar xvfp ffmpeg_linux_ia32.tar) && + (mkdir -p linux_x64 && cd linux_x64 && + 7z x ../../archives/ffmpeg_linux_x64.tar.xz && tar xvfp ffmpeg_linux_x64.tar) && + (mkdir -p win32_ia32 && cd win32_ia32 && + unzip ../../archives/ffmpeg_win32_ia32.zip) && + (mkdir -p win32_x64 && cd win32_x64 && + unzip ../../archives/ffmpeg_win32_x64.zip) && + (mkdir -p darwin_x64 && cd darwin_x64 && + 7z x ../../archives/ffmpeg_darwin_x64.7z && + 7z x ../../archives/ffprobe_darwin_x64.7z) +) && +cd ffmpeg-tmp && +mkdir -p binaries/linux_ia32 && +mkdir -p binaries/linux_x64 && +mkdir -p binaries/win32_ia32 && +mkdir -p binaries/win32_x64 && +mkdir -p binaries/darwin_x64 && +mv extracted/linux_ia32/ffmpeg-3.2-32bit-static/ffmpeg binaries/linux_ia32 && +mv extracted/linux_ia32/ffmpeg-3.2-32bit-static/ffprobe binaries/linux_ia32 && +mv extracted/linux_x64/ffmpeg-3.2-64bit-static/ffmpeg binaries/linux_x64 && +mv extracted/linux_x64/ffmpeg-3.2-64bit-static/ffprobe binaries/linux_x64 && +mv extracted/win32_ia32/ffmpeg-3.1.5-win32-static/bin/ffmpeg.exe binaries/win32_ia32 && +mv extracted/win32_ia32/ffmpeg-3.1.5-win32-static/bin/ffprobe.exe binaries/win32_ia32 && +mv extracted/win32_x64/ffmpeg-3.1.5-win64-static/bin/ffmpeg.exe binaries/win32_x64 && +mv extracted/win32_x64/ffmpeg-3.1.5-win64-static/bin/ffprobe.exe binaries/win32_x64 && +mv extracted/darwin_x64/ffmpeg binaries/darwin_x64 && +mv extracted/darwin_x64/ffprobe binaries/darwin_x64 && +echo Done diff --git a/src/ffmpeg.js b/src/ffmpeg.js index cc16618d..2380ed6e 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -3,20 +3,32 @@ const bluebird = require('bluebird'); const which = bluebird.promisify(require('which')); const path = require('path'); const util = require('./util'); +const fs = require('fs'); -const Configstore = require('configstore'); - -const configstore = new Configstore('lossless-cut', { ffmpegPath: '' }); +bluebird.promisifyAll(fs); 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)'); + alert(`Failed to run ffmpeg:\n${err.stack}`); console.error(err.stack); } +function getWithExt(name) { + return process.platform === 'win32' ? `${name}.exe` : name; +} + +function canExecuteFfmpeg(ffmpegPath) { + return execa(ffmpegPath, ['-version']); +} + function getFfmpegPath() { - return which('ffmpeg') - .catch(() => configstore.get('ffmpegPath')); + const internalFfmpeg = path.join(__dirname, '..', 'app.asar.unpacked', 'ffmpeg', getWithExt('ffmpeg')); + return canExecuteFfmpeg(internalFfmpeg) + .then(() => internalFfmpeg) + .catch(() => { + console.log('Internal ffmpeg unavail'); + return which('ffmpeg'); + }); } function cut(filePath, format, cutFrom, cutTo) { @@ -53,7 +65,7 @@ function getFormats(filePath) { console.log('getFormat', filePath); return getFfmpegPath() - .then(ffmpegPath => path.join(path.dirname(ffmpegPath), 'ffprobe')) + .then(ffmpegPath => path.join(path.dirname(ffmpegPath), getWithExt('ffprobe'))) .then(ffprobePath => execa(ffprobePath, [ '-of', 'json', '-show_format', '-i', filePath, ])) @@ -65,8 +77,6 @@ function getFormats(filePath) { }); } -// '-of', 'json', '-select_streams', 'v', '-show_frames', filePath, - module.exports = { cut, getFormats, diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 00000000..8f6b1a27 --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,59 @@ + + + diff --git a/src/index.js b/src/index.js index 1c78e0d2..8ead4a63 100644 --- a/src/index.js +++ b/src/index.js @@ -1,18 +1,14 @@ const electron = require('electron'); // eslint-disable-line -const Configstore = require('configstore'); -const bluebird = require('bluebird'); -const which = bluebird.promisify(require('which')); +const util = require('./util'); const menu = require('./menu'); const app = electron.app; 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'; +app.setName('LosslessCut'); + +if (util.isPackaged()) 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. @@ -32,48 +28,12 @@ function createWindow() { }); } -function showFfmpegDialog() { - console.log('Show ffmpeg dialog'); - return new Promise(resolve => dialog.showOpenDialog({ - defaultPath: '/usr/local/bin/ffmpeg', - properties: ['openFile', 'showHiddenFiles'], - }, ffmpegPath => resolve(ffmpegPath !== undefined ? ffmpegPath[0] : undefined))); -} - -function changeFfmpegPath() { - return showFfmpegDialog() - .then((ffmpegPath) => { - if (ffmpegPath !== undefined) configstore.set('ffmpegPath', ffmpegPath); - }); -} - -function configureFfmpeg() { - return which('ffmpeg') - .then(() => true) - .catch(() => { - if (configstore.get('ffmpegPath') !== undefined) { - return undefined; - } - - console.log('Show first time dialog'); - return new Promise(resolve => dialog.showMessageBox({ - buttons: ['OK'], - message: 'This is the first time you run LosslessCut and ffmpeg path was not auto detected. Please close this dialog and then select the path to the ffmpeg executable.', - }, resolve)) - .then(showFfmpegDialog) - .then((ffmpegPath) => { - configstore.set('ffmpegPath', ffmpegPath !== undefined ? ffmpegPath : ''); - }); - }); -} - // This method will be called when Electron has finished // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', () => { createWindow(); - menu(app, mainWindow, changeFfmpegPath); - configureFfmpeg(); + menu(app, mainWindow); }); // Quit when all windows are closed. diff --git a/src/main.css b/src/main.css index 1230d30a..541f995a 100644 --- a/src/main.css +++ b/src/main.css @@ -38,17 +38,22 @@ input, button, textarea, :focus { padding: .4em; } -.jump-cut-start, .jump-cut-end, .playback-rate { +.controls-wrapper button, .right-menu button { background: white; border-radius: .3em; color: rgba(0, 0, 0, 0.7); font-size: 60%; vertical-align: middle; - padding: .2em; + padding: .2em .4em; margin: 0 .5em; border: none; } +.controls-wrapper button:active, .right-menu button:active { + background: #ccc; +} + + .right-menu { position: absolute; right: 0; @@ -60,7 +65,7 @@ input, button, textarea, :focus { left: 0; right: 0; bottom: 0; - height: 6rem; + height: 5.75rem; background: #6b6b6b; text-align: center; } diff --git a/src/menu.js b/src/menu.js index dda2a8be..9d2fea1f 100644 --- a/src/menu.js +++ b/src/menu.js @@ -6,12 +6,13 @@ const dialog = electron.dialog; const homepage = 'https://github.com/mifi/lossless-cut'; -module.exports = (app, mainWindow, changeFfmpegPath) => { +module.exports = (app, mainWindow) => { const menu = defaultMenu(app, electron.shell); - menu.splice(1, 1); + const editMenuIndex = menu.findIndex(item => item.Label === 'Edit'); + if (editMenuIndex >= 0) menu.splice(editMenuIndex, 1); - menu.splice(1, 0, { + menu.splice((process.platform === 'darwin' ? 1 : 0), 0, { label: 'File', submenu: [ { @@ -23,22 +24,21 @@ module.exports = (app, mainWindow, changeFfmpegPath) => { }); }, }, - { - label: 'Change ffmpeg path', - click: changeFfmpegPath, - }, ], }); - menu.splice(menu.findIndex(item => item.role === 'help'), 1, { - role: 'help', - submenu: [ - { - label: 'Learn More', - click() { electron.shell.openExternal(homepage); }, - }, - ], - }); + const helpIndex = menu.findIndex(item => item.role === 'help'); + if (helpIndex >= 0) { + menu.splice(helpIndex, 1, { + role: 'help', + submenu: [ + { + label: 'Learn More', + click() { electron.shell.openExternal(homepage); }, + }, + ], + }); + } Menu.setApplicationMenu(Menu.buildFromTemplate(menu)); }; diff --git a/src/renderer.jsx b/src/renderer.jsx index e06b42ca..1298fc4f 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -1,3 +1,4 @@ +const bluebird = require('bluebird'); const electron = require('electron'); // eslint-disable-line const $ = require('jquery'); const keyboardJs = require('keyboardjs'); @@ -64,9 +65,12 @@ class App extends React.Component { }; const load = (filePath) => { + if (this.state.working) return alert('I\'m busy'); + resetState(); - ffmpeg.getFormats(filePath) + this.setState({ working: true }); + return bluebird.resolve(ffmpeg.getFormats(filePath)) .then((formats) => { if (formats.length < 1) return alert('Unsupported file'); return this.setState({ filePath, fileFormat: formats[0] }); @@ -77,7 +81,8 @@ class App extends React.Component { return; } ffmpeg.showFfmpegFail(err); - }); + }) + .finally(() => this.setState({ working: false })); }; electron.ipcRenderer.on('file-opened', (event, message) => { @@ -170,6 +175,8 @@ class App extends React.Component { } cutClick() { + if (this.state.working) return alert('I\'m busy'); + const cutStartTime = this.state.cutStartTime; const cutEndTime = this.state.cutEndTime; const filePath = this.state.filePath; @@ -181,7 +188,7 @@ class App extends React.Component { } this.setState({ working: true }); - return ffmpeg.cut(filePath, this.state.fileFormat, cutStartTime, cutEndTime) + return bluebird.resolve(ffmpeg.cut(filePath, this.state.fileFormat, cutStartTime, cutEndTime)) .finally(() => this.setState({ working: false })); } @@ -292,7 +299,7 @@ class App extends React.Component {