mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
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
This commit is contained in:
parent
3851cb264f
commit
cce542af41
21
README.md
21
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
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
}}
|
||||
>
|
||||
<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
|
||||
className="cut-start-time"
|
||||
style={{
|
||||
left: `${(this.state.cutStartTime / this.state.duration) * 100}%`,
|
||||
width: `${((this.state.cutEndTime - this.state.cutStartTime) / this.state.duration) * 100}%`,
|
||||
left: `${((this.state.cutStartTime || 0) / (this.state.duration || 1)) * 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>
|
||||
</Hammer>
|
||||
|
||||
@ -267,7 +265,7 @@ class App extends React.Component {
|
||||
<button
|
||||
className="jump-cut-start" title="Cut start time"
|
||||
onClick={() => this.jumpCutStart()}
|
||||
>{formatDuration(this.state.cutStartTime || 0)}</button>
|
||||
>{util.formatDuration(this.state.cutStartTime || 0)}</button>
|
||||
<i
|
||||
title="Set cut start"
|
||||
className="button fa fa-angle-left"
|
||||
@ -289,11 +287,14 @@ class App extends React.Component {
|
||||
<button
|
||||
className="jump-cut-end" title="Cut end time"
|
||||
onClick={() => this.jumpCutEnd()}
|
||||
>{formatDuration(this.state.cutEndTime || 0)}</button>
|
||||
>{util.formatDuration(this.state.cutEndTime || 0)}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="right-menu">
|
||||
<button className="file-format" title="Format">
|
||||
{this.state.fileFormat || '-'}
|
||||
</button>
|
||||
<button className="playback-rate" title="Playback rate">
|
||||
{_.round(this.state.playbackRate, 1) || 1}x
|
||||
</button>
|
||||
|
19
src/util.js
Normal file
19
src/util.js
Normal file
@ -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…
Reference in New Issue
Block a user