You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lossless-cut/src/renderer.jsx

712 lines
21 KiB
React

10 years ago
const electron = require('electron'); // eslint-disable-line
const $ = require('jquery');
const Mousetrap = require('mousetrap');
const round = require('lodash/round');
const clamp = require('lodash/clamp');
const throttle = require('lodash/throttle');
const Hammer = require('react-hammerjs').default;
const path = require('path');
const trash = require('trash');
10 years ago
const React = require('react');
const ReactDOM = require('react-dom');
const classnames = require('classnames');
const HelpSheet = require('./HelpSheet');
const { showMergeDialog } = require('./merge/merge');
const captureFrame = require('./capture-frame');
const ffmpeg = require('./ffmpeg');
const {
getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
promptTimeOffset,
} = require('./util');
10 years ago
const { dialog } = electron.remote;
10 years ago
function getVideo() {
return $('#player video')[0];
}
function seekAbs(val) {
const video = getVideo();
if (val == null || Number.isNaN(val)) return;
10 years ago
let outVal = val;
if (outVal < 0) outVal = 0;
if (outVal > video.duration) outVal = video.duration;
video.currentTime = outVal;
}
function setCursor(val) {
seekAbs(val);
}
function seekRel(val) {
seekAbs(getVideo().currentTime + val);
}
function shortStep(dir) {
seekRel((1 / 60) * dir);
}
function withBlur(cb) {
return (e) => {
e.target.blur();
cb();
};
}
const localState = {
working: false,
filePath: '', // Setting video src="" prevents memory leak in chromium
html5FriendlyPath: undefined,
playing: false,
currentTime: undefined,
duration: undefined,
cutStartTime: 0,
cutStartTimeManual: undefined,
cutEndTime: undefined,
cutEndTimeManual: undefined,
fileFormat: undefined,
rotation: 360,
cutProgress: undefined,
startTimeOffset: 0,
};
const globalState = {
stripAudio: false,
includeAllStreams: false,
captureFormat: 'jpeg',
customOutDir: undefined,
keyframeCut: true,
};
10 years ago
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
...localState,
...globalState,
};
10 years ago
const load = async (filePath, html5FriendlyPath) => {
const { working } = this.state;
console.log('Load', { filePath, html5FriendlyPath });
if (working) {
errorToast('I\'m busy');
return;
}
this.resetState();
this.setState({ working: true });
try {
const fileFormat = await ffmpeg.getFormat(filePath);
if (!fileFormat) {
errorToast('Unsupported file');
return;
}
setFileNameTitle(filePath);
this.setState({ filePath, html5FriendlyPath, fileFormat });
} catch (err) {
if (err.code === 1 || err.code === 'ENOENT') {
errorToast('Unsupported file');
return;
}
showFfmpegFail(err);
} finally {
this.setState({ working: false });
}
10 years ago
};
electron.ipcRenderer.on('file-opened', (event, filePaths) => {
if (!filePaths || filePaths.length !== 1) return;
load(filePaths[0]);
10 years ago
});
electron.ipcRenderer.on('html5ify', async (event, encodeVideo) => {
const { filePath, customOutDir } = this.state;
if (!filePath) return;
try {
this.setState({ working: true });
const html5ifiedPath = getOutPath(customOutDir, filePath, 'html5ified.mp4');
await ffmpeg.html5ify(filePath, html5ifiedPath, encodeVideo);
this.setState({ working: false });
load(filePath, html5ifiedPath);
} catch (err) {
errorToast('Failed to html5ify file');
console.error('Failed to html5ify file', err);
this.setState({ working: false });
}
});
electron.ipcRenderer.on('show-merge-dialog', () => showMergeDialog({
dialog,
defaultPath: this.getOutputDir(),
onMergeClick: async (paths) => {
try {
this.setState({ working: true });
// TODO customOutDir ?
// console.log('merge', paths);
await ffmpeg.mergeFiles(paths);
} catch (err) {
errorToast('Failed to merge files. Make sure they are all of the exact same format and codecs');
console.error('Failed to merge files', err);
} finally {
this.setState({ working: false });
}
},
}));
electron.ipcRenderer.on('set-start-offset', async () => {
const { startTimeOffset: startTimeOffsetOld } = this.state;
const startTimeOffset = await promptTimeOffset(
startTimeOffsetOld !== undefined ? formatDuration(startTimeOffsetOld) : undefined,
);
if (startTimeOffset === undefined) {
console.log('Cancelled');
return;
}
this.setState({ startTimeOffset });
});
electron.ipcRenderer.on('extract-all-streams', async () => {
const { filePath } = this.state;
if (!filePath) return;
try {
this.setState({ working: true });
// TODO customOutDir ?
await ffmpeg.extractAllStreams(filePath);
this.setState({ working: false });
} catch (err) {
errorToast('Failed to extract all streams');
console.error('Failed to extract all streams', err);
this.setState({ working: false });
}
});
document.ondragover = ev => ev.preventDefault();
document.ondragend = document.ondragover;
10 years ago
document.body.ondrop = (ev) => {
ev.preventDefault();
if (ev.dataTransfer.files.length !== 1) {
errorToast('Please drop only one file');
return;
}
10 years ago
load(ev.dataTransfer.files[0].path);
};
Mousetrap.bind('space', () => this.playCommand());
Mousetrap.bind('k', () => this.playCommand());
Mousetrap.bind('j', () => this.changePlaybackRate(-1));
Mousetrap.bind('l', () => this.changePlaybackRate(1));
Mousetrap.bind('left', () => seekRel(-1));
Mousetrap.bind('right', () => seekRel(1));
Mousetrap.bind('.', () => shortStep(1));
Mousetrap.bind(',', () => shortStep(-1));
Mousetrap.bind('c', () => this.capture());
Mousetrap.bind('e', () => this.cutClick());
Mousetrap.bind('i', () => this.setCutStart());
Mousetrap.bind('o', () => this.setCutEnd());
Mousetrap.bind('h', () => this.toggleHelp());
electron.ipcRenderer.send('renderer-ready');
10 years ago
}
onPlay(playing) {
this.setState({ playing });
if (!playing) {
getVideo().playbackRate = 1;
}
}
onDurationChange(duration) {
this.setState({ duration });
}
onCutProgress = (cutProgress) => {
this.setState({ cutProgress });
}
setCutStart = () => {
this.setState(({ currentTime }) => ({ cutStartTime: currentTime }));
}
setCutEnd = () => {
this.setState(({ currentTime }) => ({ cutEndTime: currentTime }));
}
setOutputDir = () => {
dialog.showOpenDialog({ properties: ['openDirectory'] }, (paths) => {
this.setState({ customOutDir: (paths && paths.length === 1) ? paths[0] : undefined });
});
}
10 years ago
getFileUri() {
const { html5FriendlyPath, filePath } = this.state;
return (html5FriendlyPath || filePath || '').replace(/#/g, '%23');
10 years ago
}
getOutputDir() {
const { customOutDir, filePath } = this.state;
if (customOutDir) return customOutDir;
if (filePath) return path.dirname(filePath);
return undefined;
}
getRotation() {
return this.state.rotation;
}
getRotationStr() {
return `${this.getRotation()}°`;
}
getApparentCutEndTime() {
if (this.state.cutEndTime !== undefined) return this.state.cutEndTime;
if (this.state.duration !== undefined) return this.state.duration;
return 0; // Haven't gotten duration yet
}
getOffsetCurrentTime() {
return (this.state.currentTime || 0) + this.state.startTimeOffset;
}
increaseRotation = () => {
this.setState(({ rotation }) => ({ rotation: (rotation + 90) % 450 }));
}
toggleCaptureFormat = () => {
const isPng = this.state.captureFormat === 'png';
this.setState({ captureFormat: isPng ? 'jpeg' : 'png' });
}
toggleIncludeAllStreams = () => {
this.setState(({ includeAllStreams }) => ({ includeAllStreams: !includeAllStreams }));
}
toggleStripAudio = () => this.setState(({ stripAudio }) => ({ stripAudio: !stripAudio }));
toggleKeyframeCut = () => this.setState(({ keyframeCut }) => ({ keyframeCut: !keyframeCut }));
jumpCutStart = () => {
seekAbs(this.state.cutStartTime);
}
jumpCutEnd = () => {
seekAbs(this.getApparentCutEndTime());
}
/* eslint-disable react/sort-comp */
handleTap = throttle((e) => {
const $target = $('.timeline-wrapper');
10 years ago
const parentOffset = $target.offset();
const relX = e.srcEvent.pageX - parentOffset.left;
10 years ago
setCursor((relX / $target[0].offsetWidth) * this.state.duration);
}, 200);
/* eslint-enable react/sort-comp */
10 years ago
playbackRateChange = () => {
this.state.playbackRate = getVideo().playbackRate;
}
playCommand = () => {
const video = getVideo();
if (this.state.playing) return video.pause();
return video.play().catch((err) => {
console.log(err);
if (err.name === 'NotSupportedError') {
toast.fire({ type: 'error', title: 'This format/codec is not supported. Try to convert it to a friendly format/codec in the player from the "File" menu. Note that this will only create a temporary, low quality encoded file used for previewing your cuts, and will not affect the final cut. The final cut will still be lossless. Audio is also removed to make it faster, but only in the preview.', timer: 10000 });
}
});
10 years ago
}
deleteSourceClick = async () => {
// eslint-disable-next-line no-alert
if (this.state.working || !window.confirm('Are you sure you want to move the source file to trash?')) return;
const { filePath } = this.state;
this.setState({ working: true });
await trash(filePath);
this.resetState();
}
cutClick = async () => {
if (this.state.working) {
errorToast('I\'m busy');
return;
}
const {
cutStartTime, cutEndTime, filePath, customOutDir, fileFormat, duration, includeAllStreams,
stripAudio, keyframeCut,
} = this.state;
const rotation = this.isRotationSet() ? this.getRotation() : undefined;
if (!this.isCutRangeValid()) {
errorToast('Start time must be before end time');
return;
10 years ago
}
this.setState({ working: true });
try {
await ffmpeg.cut({
customOutDir,
filePath,
format: fileFormat,
cutFrom: cutStartTime,
cutTo: cutEndTime,
cutToApparent: this.getApparentCutEndTime(),
videoDuration: duration,
rotation,
includeAllStreams,
stripAudio,
keyframeCut,
onProgress: this.onCutProgress,
});
} catch (err) {
9 years ago
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
9 years ago
if (err.code === 1 || err.code === 'ENOENT') {
errorToast('Whoops! ffmpeg was unable to cut this video. It may be of an unknown format or codec combination');
return;
}
showFfmpegFail(err);
} finally {
this.setState({ working: false });
9 years ago
}
10 years ago
}
capture = async () => {
const {
filePath, customOutDir: outputDir, currentTime, captureFormat,
} = this.state;
if (!filePath) return;
try {
await captureFrame(outputDir, filePath, getVideo(), currentTime, captureFormat);
} catch (err) {
console.error(err);
errorToast('Failed to capture frame');
}
10 years ago
}
changePlaybackRate(dir) {
const video = getVideo();
if (!this.state.playing) {
video.playbackRate = 0.5; // dir * 0.5;
video.play();
} else {
const newRate = video.playbackRate + (dir * 0.15);
video.playbackRate = clamp(newRate, 0.05, 16);
}
}
resetState() {
const video = getVideo();
video.currentTime = 0;
video.playbackRate = 1;
this.setState(localState);
setFileNameTitle();
}
isRotationSet() {
// 360 means we don't modify rotation
return this.state.rotation !== 360;
}
isCutRangeValid() {
return this.state.cutStartTime < this.getApparentCutEndTime();
}
toggleHelp() {
this.setState(({ helpVisible }) => ({ helpVisible: !helpVisible }));
}
renderCutTimeInput(type) {
const cutTimeManualKey = type === 'start' ? 'cutStartTimeManual' : 'cutEndTimeManual';
const cutTimeInputStyle = { width: '8em', textAlign: type === 'start' ? 'right' : 'left' };
const isCutTimeManualSet = () => this.state[cutTimeManualKey] !== undefined;
const handleCutTimeInput = (text) => {
// Allow the user to erase
if (text.length === 0) {
this.setState({ [cutTimeManualKey]: undefined });
return;
}
const time = parseDuration(text);
if (time === undefined) {
this.setState({ [cutTimeManualKey]: text });
return;
}
const cutTimeKey = type === 'start' ? 'cutStartTime' : 'cutEndTime';
this.setState(state => ({
[cutTimeManualKey]: undefined,
[cutTimeKey]: time - state.startTimeOffset,
}));
};
const cutTime = type === 'start' ? this.state.cutStartTime : this.getApparentCutEndTime();
return (
<input
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
type="text"
onChange={e => handleCutTimeInput(e.target.value)}
value={isCutTimeManualSet()
? this.state[cutTimeManualKey]
: formatDuration(cutTime + this.state.startTimeOffset)
}
/>
);
}
10 years ago
render() {
const jumpCutButtonStyle = {
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
};
const infoSpanStyle = {
background: 'rgba(255, 255, 255, 0.4)', padding: '.1em .4em', margin: '0 3px', fontSize: 13, borderRadius: '.3em',
};
return (
<div>
{!this.state.filePath && (
<div id="drag-drop-field">
<div style={{ fontSize: '9vw' }}>DROP VIDEO</div>
<div>PRESS H FOR HELP</div>
</div>
)}
{this.state.working && (
<div style={{
color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: 0, left: 0,
}}
>
<i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} />
{this.state.cutProgress != null && (
<span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}>
{`${Math.floor(this.state.cutProgress * 100)} %`}
</span>
)}
</div>
)}
{/* eslint-disable jsx-a11y/media-has-caption */}
<div id="player">
<video
src={this.getFileUri()}
onRateChange={this.playbackRateChange}
onPlay={() => this.onPlay(true)}
onPause={() => this.onPlay(false)}
onDurationChange={e => this.onDurationChange(e.target.duration)}
onTimeUpdate={e => this.setState({ currentTime: e.target.currentTime })}
/>
</div>
{/* eslint-enable jsx-a11y/media-has-caption */}
<div className="controls-wrapper">
<Hammer
onTap={this.handleTap}
onPan={this.handleTap}
options={{ recognizers: {} }}
>
<div className="timeline-wrapper">
<div className="current-time" style={{ left: `${((this.state.currentTime || 0) / (this.state.duration || 1)) * 100}%` }} />
{this.isCutRangeValid() && (
8 years ago
<div
className="cut-start-time"
style={{
left: `${((this.state.cutStartTime) / (this.state.duration || 1)) * 100}%`,
width: `${(((this.getApparentCutEndTime()) - this.state.cutStartTime) / (this.state.duration || 1)) * 100}%`,
8 years ago
}}
/>
)
}
<div id="current-time-display">{formatDuration(this.getOffsetCurrentTime())}</div>
</div>
</Hammer>
10 years ago
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<i
className="button fa fa-step-backward"
aria-hidden="true"
title="Jump to start of video"
onClick={() => seekAbs(0)}
/>
<div style={{ position: 'relative' }}>
{this.renderCutTimeInput('start')}
<i
style={{ ...jumpCutButtonStyle, left: 0 }}
className="fa fa-step-backward"
title="Jump to cut start"
aria-hidden="true"
onClick={withBlur(this.jumpCutStart)}
/>
</div>
<i
className="button fa fa-caret-left"
aria-hidden="true"
onClick={() => shortStep(-1)}
/>
<i
className={classnames({
button: true, fa: true, 'fa-pause': this.state.playing, 'fa-play': !this.state.playing,
})}
aria-hidden="true"
onClick={this.playCommand}
/>
<i
className="button fa fa-caret-right"
aria-hidden="true"
onClick={() => shortStep(1)}
/>
<div style={{ position: 'relative' }}>
{this.renderCutTimeInput('end')}
<i
style={{ ...jumpCutButtonStyle, right: 0 }}
className="fa fa-step-forward"
title="Jump to cut end"
aria-hidden="true"
onClick={withBlur(this.jumpCutEnd)}
/>
</div>
<i
className="button fa fa-step-forward"
aria-hidden="true"
title="Jump to end of video"
onClick={() => seekAbs(this.state.duration)}
/>
</div>
<div>
<i
title="Set cut start to current position"
className="button fa fa-angle-left"
aria-hidden="true"
onClick={this.setCutStart}
/>
<i
title="Cut"
className="button fa fa-scissors"
aria-hidden="true"
onClick={this.cutClick}
/>
<i
title="Delete source file"
className="button fa fa-trash"
aria-hidden="true"
onClick={this.deleteSourceClick}
/>
<i
title="Set cut end to current position"
className="button fa fa-angle-right"
aria-hidden="true"
onClick={this.setCutEnd}
/>
</div>
10 years ago
</div>
<div className="left-menu">
<span style={infoSpanStyle} title="Format of current file">
{this.state.fileFormat || 'FMT'}
</span>
<span style={infoSpanStyle} title="Playback rate">
{round(this.state.playbackRate, 1) || 1}
</span>
</div>
<div className="right-menu">
<button
type="button"
title={`Cut mode ${this.state.keyframeCut ? 'nearest keyframe cut' : 'normal cut'}`}
onClick={withBlur(this.toggleKeyframeCut)}
>
{this.state.keyframeCut ? 'kc' : 'nc'}
</button>
<button
type="button"
title={`Set output streams. Current: ${this.state.includeAllStreams ? 'include (and cut) all streams' : 'include only primary streams'}`}
onClick={withBlur(this.toggleIncludeAllStreams)}
>
{this.state.includeAllStreams ? 'all' : 'ps'}
</button>
<button
type="button"
title={`Delete audio? Current: ${this.state.stripAudio ? 'delete audio tracks' : 'keep audio tracks'}`}
onClick={withBlur(this.toggleStripAudio)}
>
{this.state.stripAudio ? 'da' : 'ka'}
</button>
<button
type="button"
title={`Set output rotation. Current: ${this.isRotationSet() ? this.getRotationStr() : 'Don\'t modify'}`}
onClick={withBlur(this.increaseRotation)}
>
{this.isRotationSet() ? this.getRotationStr() : '-°'}
</button>
<button
type="button"
title={`Custom output dir (cancel to restore default). Current: ${this.getOutputDir() || 'Not set (use input dir)'}`}
onClick={withBlur(this.setOutputDir)}
>
{this.getOutputDir() ? 'cd' : 'id'}
</button>
<i
title="Capture frame"
style={{ margin: '-.4em -.2em' }}
className="button fa fa-camera"
aria-hidden="true"
onClick={this.capture}
/>
<button
type="button"
title="Capture frame format"
onClick={withBlur(this.toggleCaptureFormat)}
>
{this.state.captureFormat}
</button>
</div>
<HelpSheet visible={!!this.state.helpVisible} />
10 years ago
</div>
);
10 years ago
}
}
ReactDOM.render(<App />, document.getElementById('app'));
console.log('Version', electron.remote.app.getVersion());