Adds support for more formats by detecting format and using same format for output file (don't always use mp4)

Also:
Improve ffmpeg error handling
React enable production
Improve react rendering a bit
pull/13/head
Mikael Finstad 10 years ago
parent 3851cb264f
commit cce542af41

@ -1,5 +1,5 @@
# LosslessCut 🎥 [![Travis](https://img.shields.io/travis/mifi/lossless-cut.svg)]() # 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) ![Demo](demo.gif)
@ -38,7 +38,7 @@ The original video files will not be modified. Instead it creates a lossless exp
## Development building / running ## 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 git clone https://github.com/mifi/lossless-cut.git
cd lossless-cut cd lossless-cut
@ -46,6 +46,11 @@ npm install
``` ```
### Running ### Running
In one terminal:
```
npm run watch
```
Then:
``` ```
npm start npm start
``` ```
@ -59,14 +64,12 @@ npm run package
## TODO / ideas ## TODO / ideas
- About menu - About menu
- icon - icon
- Bundle ffmpeg
- Visual feedback on button presses - Visual feedback on button presses
- ffprobe show keyframes - support for previewing other formats by streaming through ffmpeg?
- ffprobe format
- improve ffmpeg error handling
- Slow scrub with modifier key - Slow scrub with modifier key
- show frame number - show frame number (approx?)
- Bundle ffmpeg - ffprobe show keyframes
- support for loading other formats by streaming through ffmpeg?
- cutting out the commercials in a video file while saving the rest to a single file? - cutting out the commercials in a video file while saving the rest to a single file?
## Links ## Links

@ -1,25 +1,74 @@
const execa = require('execa'); const execa = require('execa');
const bluebird = require('bluebird'); const bluebird = require('bluebird');
const which = bluebird.promisify(require('which')); const which = bluebird.promisify(require('which'));
const path = require('path');
const util = require('./util');
const Configstore = require('configstore'); const Configstore = require('configstore');
const configstore = new Configstore('lossless-cut', { ffmpegPath: '' }); const configstore = new Configstore('lossless-cut', { ffmpegPath: '' });
module.exports.cut = (filePath, cutFrom, cutTo, outFile) => { function showFfmpegFail(err) {
console.log('Cutting from', cutFrom, 'to', cutTo); 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') return which('ffmpeg')
.catch(() => configstore.get('ffmpegPath')) .catch(() => configstore.get('ffmpegPath'));
.then(ffmpegPath => execa(ffmpegPath, [ }
'-i', filePath, '-y', '-vcodec', 'copy', '-acodec', 'copy', '-ss', cutFrom, '-t', cutTo - cutFrom, outFile,
])) 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);
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) => { .then((result) => {
console.log(result.stdout); console.log(result.stdout);
}) })
.catch((err) => { .catch((err) => {
console.error(err.stack); if (err.code === 1) {
alert(`Failed to run ffmpeg, make sure you have it installed and in available in your PATH or its path configured in ${configstore.path}`); 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,
}; };

@ -10,6 +10,10 @@ const BrowserWindow = electron.BrowserWindow;
const dialog = electron.dialog; const dialog = electron.dialog;
const configstore = new Configstore('lossless-cut'); 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 // 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. // be closed automatically when the JavaScript object is garbage collected.
let mainWindow; let mainWindow;

@ -1,7 +1,6 @@
const electron = require('electron'); // eslint-disable-line const electron = require('electron'); // eslint-disable-line
const $ = require('jquery'); const $ = require('jquery');
const keyboardJs = require('keyboardjs'); const keyboardJs = require('keyboardjs');
const ffmpeg = require('./ffmpeg');
const _ = require('lodash'); const _ = require('lodash');
const captureFrame = require('capture-frame'); const captureFrame = require('capture-frame');
const fs = require('fs'); const fs = require('fs');
@ -11,19 +10,8 @@ const React = require('react');
const ReactDOM = require('react-dom'); const ReactDOM = require('react-dom');
const classnames = require('classnames'); const classnames = require('classnames');
function formatDuration(_seconds) { const ffmpeg = require('./ffmpeg');
const seconds = _seconds || 0; const util = require('./util');
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}`;
}
function getVideo() { function getVideo() {
return $('#player video')[0]; return $('#player video')[0];
@ -63,6 +51,7 @@ class App extends React.Component {
duration: undefined, duration: undefined,
cutStartTime: 0, cutStartTime: 0,
cutEndTime: undefined, cutEndTime: undefined,
fileFormat: undefined,
}; };
this.state = _.cloneDeep(defaultState); this.state = _.cloneDeep(defaultState);
@ -76,7 +65,19 @@ class App extends React.Component {
const load = (filePath) => { const load = (filePath) => {
resetState(); 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) => { electron.ipcRenderer.on('file-opened', (event, message) => {
@ -180,17 +181,14 @@ class App extends React.Component {
} }
this.setState({ working: true }); this.setState({ working: true });
const ext = 'mp4'; return ffmpeg.cut(filePath, this.state.fileFormat, cutStartTime, cutEndTime)
const outFileAppend = `${formatDuration(cutStartTime)}-${formatDuration(cutEndTime)}`;
const outPath = `${filePath}-${outFileAppend}.${ext}`;
return ffmpeg.cut(filePath, cutStartTime, cutEndTime, outPath)
.finally(() => this.setState({ working: false })); .finally(() => this.setState({ working: false }));
} }
capture() { capture() {
if (!this.state.filePath) return; if (!this.state.filePath) return;
const buf = captureFrame(getVideo(), 'jpg'); 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) => { fs.writeFile(outPath, buf, (err) => {
if (err) alert(err); if (err) alert(err);
}); });
@ -223,16 +221,16 @@ class App extends React.Component {
}} }}
> >
<div className="timeline-wrapper"> <div className="timeline-wrapper">
<div className="current-time" style={{ left: `${(this.state.currentTime / this.state.duration) * 100}%` }} /> <div className="current-time" style={{ left: `${((this.state.currentTime || 0) / (this.state.duration || 1)) * 100}%` }} />
<div <div
className="cut-start-time" className="cut-start-time"
style={{ style={{
left: `${(this.state.cutStartTime / this.state.duration) * 100}%`, left: `${((this.state.cutStartTime || 0) / (this.state.duration || 1)) * 100}%`,
width: `${((this.state.cutEndTime - this.state.cutStartTime) / this.state.duration) * 100}%`, width: `${(((this.state.cutEndTime || 0) - (this.state.cutStartTime || 0)) / (this.state.duration || 1)) * 100}%`,
}} }}
/> />
<div id="current-time-display">{formatDuration(this.state.currentTime)}</div> <div id="current-time-display">{util.formatDuration(this.state.currentTime)}</div>
</div> </div>
</Hammer> </Hammer>
@ -267,7 +265,7 @@ class App extends React.Component {
<button <button
className="jump-cut-start" title="Cut start time" className="jump-cut-start" title="Cut start time"
onClick={() => this.jumpCutStart()} onClick={() => this.jumpCutStart()}
>{formatDuration(this.state.cutStartTime || 0)}</button> >{util.formatDuration(this.state.cutStartTime || 0)}</button>
<i <i
title="Set cut start" title="Set cut start"
className="button fa fa-angle-left" className="button fa fa-angle-left"
@ -289,11 +287,14 @@ class App extends React.Component {
<button <button
className="jump-cut-end" title="Cut end time" className="jump-cut-end" title="Cut end time"
onClick={() => this.jumpCutEnd()} onClick={() => this.jumpCutEnd()}
>{formatDuration(this.state.cutEndTime || 0)}</button> >{util.formatDuration(this.state.cutEndTime || 0)}</button>
</div> </div>
</div> </div>
<div className="right-menu"> <div className="right-menu">
<button className="file-format" title="Format">
{this.state.fileFormat || '-'}
</button>
<button className="playback-rate" title="Playback rate"> <button className="playback-rate" title="Playback rate">
{_.round(this.state.playbackRate, 1) || 1}x {_.round(this.state.playbackRate, 1) || 1}x
</button> </button>

@ -0,0 +1,19 @@
const _ = require('lodash');
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}`;
}
module.exports = {
formatDuration,
};
Loading…
Cancel
Save