const electron = require('electron'); // eslint-disable-line const $ = require('jquery'); const Mousetrap = require('mousetrap'); const _ = require('lodash'); const Hammer = require('react-hammerjs'); const path = require('path'); const React = require('react'); const ReactDOM = require('react-dom'); const classnames = require('classnames'); const captureFrame = require('./capture-frame'); const ffmpeg = require('./ffmpeg'); const util = require('./util'); const dialog = electron.remote.dialog; function setFileNameTitle(filePath) { const appName = 'LosslessCut'; document.title = filePath ? `${appName} - ${path.basename(filePath)}` : 'appName'; } function getVideo() { return $('#player video')[0]; } function seekAbs(val) { const video = getVideo(); if (val == null || isNaN(val)) return; 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 renderHelpSheet(visible) { if (visible) { return (

Keyboard shortcuts

); } return undefined; } function withBlur(cb) { return (e) => { e.target.blur(); cb(); }; } class App extends React.Component { constructor(props) { super(props); const defaultState = { working: false, filePath: '', // Setting video src="" prevents memory leak in chromium playing: false, currentTime: undefined, duration: undefined, cutStartTime: 0, cutStartTimeManual: undefined, cutEndTime: undefined, cutEndTimeManual: undefined, fileFormat: undefined, captureFormat: 'jpeg', rotation: 360, }; this.state = _.cloneDeep(defaultState); const resetState = () => { const video = getVideo(); video.currentTime = 0; video.playbackRate = 1; this.setState(defaultState); }; const load = (filePath) => { console.log('Load', filePath); if (this.state.working) return alert('I\'m busy'); resetState(); setFileNameTitle(); this.setState({ working: true }); return ffmpeg.getFormat(filePath) .then((fileFormat) => { if (!fileFormat) return alert('Unsupported file'); setFileNameTitle(filePath); return this.setState({ filePath, fileFormat }); }) .catch((err) => { if (err.code === 1 || err.code === 'ENOENT') { alert('Unsupported file'); return; } ffmpeg.showFfmpegFail(err); }) .finally(() => this.setState({ working: false })); }; electron.ipcRenderer.on('file-opened', (event, filePaths) => { if (!filePaths || filePaths.length !== 1) return; load(filePaths[0]); }); document.ondragover = document.ondragend = ev => ev.preventDefault(); document.body.ondrop = (ev) => { ev.preventDefault(); if (ev.dataTransfer.files.length !== 1) return; 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'); } onPlay(playing) { this.setState({ playing }); if (!playing) { getVideo().playbackRate = 1; } } onDurationChange(duration) { this.setState({ duration }); if (!this.state.cutEndTime) this.setState({ cutEndTime: duration }); } onCutProgress(cutProgress) { this.setState({ cutProgress }); } setCutStart() { this.setState({ cutStartTime: this.state.currentTime }); } setCutEnd() { this.setState({ cutEndTime: this.state.currentTime }); } setOutputDir() { dialog.showOpenDialog({ properties: ['openDirectory'] }, (paths) => { this.setState({ customOutDir: (paths && paths.length === 1) ? paths[0] : undefined }); }); } getFileUri() { return (this.state.filePath || '').replace(/#/g, '%23'); } getOutputDir() { if (this.state.customOutDir) return this.state.customOutDir; if (this.state.filePath) return path.dirname(this.state.filePath); return undefined; } getRotation() { return this.state.rotation; } getRotationStr() { return `${this.getRotation()}°`; } isRotationSet() { // 360 means we don't modify rotation return this.state.rotation !== 360; } areCutTimesSet() { return (this.state.cutStartTime !== undefined || this.state.cutEndTime !== undefined); } isCutRangeValid() { return this.areCutTimesSet() && this.state.cutStartTime < this.state.cutEndTime; } increaseRotation() { const rotation = (this.state.rotation + 90) % 450; this.setState({ rotation }); } toggleCaptureFormat() { const isPng = this.state.captureFormat === 'png'; this.setState({ captureFormat: isPng ? 'jpeg' : 'png' }); } jumpCutStart() { seekAbs(this.state.cutStartTime); } jumpCutEnd() { seekAbs(this.state.cutEndTime); } handlePan(e) { _.throttle(e2 => this.handleTap(e2), 200)(e); } handleTap(e) { const $target = $('.timeline-wrapper'); const parentOffset = $target.offset(); const relX = e.srcEvent.pageX - parentOffset.left; setCursor((relX / $target[0].offsetWidth) * this.state.duration); } 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); } } 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') { alert('This video format is not supported, maybe you can re-format the file first using ffmpeg'); } }); } async 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; const outputDir = this.state.customOutDir; const fileFormat = this.state.fileFormat; const videoDuration = this.state.duration; const rotation = this.isRotationSet() ? this.getRotation() : undefined; if (!this.areCutTimesSet()) { return alert('Please select both start and end time'); } if (!this.isCutRangeValid()) { return alert('Start time must be before end time'); } this.setState({ working: true }); try { return await ffmpeg.cut({ customOutDir: outputDir, filePath, format: fileFormat, cutFrom: cutStartTime, cutTo: cutEndTime, videoDuration, rotation, onProgress: progress => this.onCutProgress(progress), }); } catch (err) { console.error('stdout:', err.stdout); console.error('stderr:', err.stderr); if (err.code === 1 || err.code === 'ENOENT') { return alert('Whoops! ffmpeg was unable to cut this video. It may be of an unknown format or codec combination'); } return ffmpeg.showFfmpegFail(err); } finally { this.setState({ working: false }); } } capture() { const filePath = this.state.filePath; const outputDir = this.state.customOutDir; const currentTime = this.state.currentTime; const captureFormat = this.state.captureFormat; if (!filePath) return; captureFrame(outputDir, filePath, getVideo(), currentTime, captureFormat) .catch(err => alert(err)); } toggleHelp() { this.setState({ helpVisible: !this.state.helpVisible }); } renderCutTimeInput(type) { const cutTimeKey = type === 'start' ? 'cutStartTime' : 'cutEndTime'; 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 = util.parseDuration(text); if (time === undefined) { this.setState({ [cutTimeManualKey]: text }); return; } this.setState({ [cutTimeManualKey]: undefined, [cutTimeKey]: time }); }; return ( handleCutTimeInput(e.target.value)} value={isCutTimeManualSet() ? this.state[cutTimeManualKey] : util.formatDuration(this.state[cutTimeKey]) } />); } render() { const jumpCutButtonStyle = { position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px' }; return (
{!this.state.filePath &&
DROP VIDEO
} {this.state.working && (
{Math.floor((this.state.cutProgress || 0) * 100)} %
)}
this.handleTap(e)} onPan={e => this.handlePan(e)} options={{ recognizers: { }, }} >
{this.isCutRangeValid() &&
}
{util.formatDuration(this.state.currentTime)}