mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-25 03:33:14 +01:00
Add new features
Add more hotkeys More buttons Show in/out point timestamps and click to jump to these Scrubbing Show error for unsupported files
This commit is contained in:
parent
e5c81f0ea8
commit
9044f682fd
22
README.md
22
README.md
@ -29,6 +29,9 @@ The original video files will not be modified. Instead it creates a lossless exp
|
|||||||
- <kbd>→</kbd> Seek forward 1 sec
|
- <kbd>→</kbd> Seek forward 1 sec
|
||||||
- <kbd>.</kbd> (period) Tiny seek forward
|
- <kbd>.</kbd> (period) Tiny seek forward
|
||||||
- <kbd>,</kbd> (comma) Tiny seek backward
|
- <kbd>,</kbd> (comma) Tiny seek backward
|
||||||
|
- <kbd>i</kbd> Mark in / cut start point
|
||||||
|
- <kbd>o</kbd> Mark out / cut end point
|
||||||
|
- <kbd>e</kbd> Export selection (in the same dir as the video)
|
||||||
- <kbd>c</kbd> Capture snapshot (in the same dir as the video)
|
- <kbd>c</kbd> Capture snapshot (in the same dir as the video)
|
||||||
|
|
||||||
## Development building / running
|
## Development building / running
|
||||||
@ -51,16 +54,23 @@ npm run build
|
|||||||
npm run package
|
npm run package
|
||||||
```
|
```
|
||||||
|
|
||||||
## TODO
|
## TODO / ideas
|
||||||
- more hotkeys
|
|
||||||
- ffprobe show keyframes?
|
|
||||||
- ffprobe format
|
|
||||||
- About menu
|
- About menu
|
||||||
|
- icon
|
||||||
|
- Visual feedback on button presses
|
||||||
|
- ffprobe show keyframes
|
||||||
|
- ffprobe format
|
||||||
- improve ffmpeg error handling
|
- improve ffmpeg error handling
|
||||||
- timeline scrub support
|
- Slow scrub with modifier key
|
||||||
|
- show frame number
|
||||||
|
- Bundle ffmpeg
|
||||||
|
- 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?
|
||||||
|
|
||||||
## Links
|
## Links
|
||||||
- http://apple.stackexchange.com/questions/117306/what-options-are-available-to-losslessly-trim-mp4-m4v-video-on-10-8-or-above
|
- http://apple.stackexchange.com/questions/117306/what-options-are-available-to-losslessly-trim-mp4-m4v-video-on-10-8-or-above
|
||||||
- https://www.google.no/webhp?sourceid=chrome-instant&ion=1&espv=2&ie=UTF-8#q=lossless%20cut%20video
|
- 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/blob/master/docs/api/file-object.md
|
||||||
- https://github.com/electron/electron/issues/2538
|
- https://github.com/electron/electron/issues/2538
|
||||||
|
@ -40,6 +40,7 @@
|
|||||||
"lodash": "^4.16.4",
|
"lodash": "^4.16.4",
|
||||||
"react": "^15.3.2",
|
"react": "^15.3.2",
|
||||||
"react-dom": "^15.3.2",
|
"react-dom": "^15.3.2",
|
||||||
|
"react-hammerjs": "^0.5.0",
|
||||||
"which": "^1.2.11"
|
"which": "^1.2.11"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
39
src/main.css
39
src/main.css
@ -34,10 +34,27 @@ input, button, textarea, :focus {
|
|||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
#play, #set-start, #set-end, #seek-start, #seek-end, #cut {
|
.button {
|
||||||
padding: .4em;
|
padding: .4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jump-cut-start, .jump-cut-end {
|
||||||
|
background: white;
|
||||||
|
border-radius: .3em;
|
||||||
|
color: rgba(0, 0, 0, 0.7);
|
||||||
|
font-size: 60%;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: .2em;
|
||||||
|
margin: 0 .5em;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.controls-wrapper {
|
.controls-wrapper {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -60,7 +77,7 @@ input, button, textarea, :focus {
|
|||||||
background-color: #444;
|
background-color: #444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline-wrapper .current-time, .timeline-wrapper .cursor-time, .timeline-wrapper .cut-start-time, .timeline-wrapper .cut-end-time {
|
.timeline-wrapper .current-time, .timeline-wrapper .cursor-time, .timeline-wrapper .cut-start-time {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
@ -69,6 +86,7 @@ input, button, textarea, :focus {
|
|||||||
|
|
||||||
.timeline-wrapper .current-time {
|
.timeline-wrapper .current-time {
|
||||||
background-color: red;
|
background-color: red;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
.timeline-wrapper .cursor-time {
|
.timeline-wrapper .cursor-time {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
@ -79,10 +97,6 @@ input, button, textarea, :focus {
|
|||||||
border-right: 1px solid black;
|
border-right: 1px solid black;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
.timeline-wrapper .cut-end-time {
|
|
||||||
background-color: green;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#working {
|
#working {
|
||||||
color: white;
|
color: white;
|
||||||
@ -97,10 +111,11 @@ input, button, textarea, :focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#drag-drop-field {
|
#drag-drop-field {
|
||||||
padding: 5rem;
|
padding: 15vw 0;
|
||||||
border: 1rem dashed #252525;
|
border: 2vw dashed #252525;
|
||||||
color: #252525;
|
color: #252525;
|
||||||
margin: 3rem;
|
margin: 7vw;
|
||||||
font-size: 3em;
|
font-size: 9vw;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
182
src/renderer.jsx
182
src/renderer.jsx
@ -5,6 +5,7 @@ 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');
|
||||||
|
const Hammer = require('react-hammerjs');
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const ReactDOM = require('react-dom');
|
const ReactDOM = require('react-dom');
|
||||||
@ -91,18 +92,51 @@ class App extends React.Component {
|
|||||||
keyboardJs.bind('right', () => seekRel(1));
|
keyboardJs.bind('right', () => seekRel(1));
|
||||||
keyboardJs.bind('period', () => shortStep(1));
|
keyboardJs.bind('period', () => shortStep(1));
|
||||||
keyboardJs.bind('comma', () => shortStep(-1));
|
keyboardJs.bind('comma', () => shortStep(-1));
|
||||||
keyboardJs.bind('c', this.capture);
|
keyboardJs.bind('c', () => this.capture());
|
||||||
|
keyboardJs.bind('e', () => this.cutClick());
|
||||||
|
keyboardJs.bind('i', () => this.setCutStart());
|
||||||
|
keyboardJs.bind('o', () => this.setCutEnd());
|
||||||
}
|
}
|
||||||
|
|
||||||
mouseDown(e) {
|
setCutStart() {
|
||||||
const $target = $('.timeline-wrapper'); // $(e.target);
|
this.setState({ cutStartTime: this.state.currentTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
setCutEnd() {
|
||||||
|
this.setState({ cutEndTime: this.state.currentTime });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 parentOffset = $target.offset();
|
||||||
const relX = e.pageX - parentOffset.left;
|
const relX = e.srcEvent.pageX - parentOffset.left;
|
||||||
setCursor((relX / $target[0].offsetWidth) * this.state.duration);
|
setCursor((relX / $target[0].offsetWidth) * this.state.duration);
|
||||||
}
|
}
|
||||||
|
|
||||||
playCommand() {
|
playCommand() {
|
||||||
getVideo()[this.state.playing ? 'pause' : 'play']();
|
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');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
cutClick() {
|
cutClick() {
|
||||||
@ -125,6 +159,7 @@ class App extends React.Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
capture() {
|
capture() {
|
||||||
|
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}-${formatDuration(this.state.currentTime)}.jpg`;
|
||||||
fs.writeFile(outPath, buf, (err) => {
|
fs.writeFile(outPath, buf, (err) => {
|
||||||
@ -149,70 +184,89 @@ class App extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="controls-wrapper">
|
<div className="controls-wrapper">
|
||||||
<div className="timeline-wrapper" onMouseDown={e => this.mouseDown(e)}>
|
<Hammer
|
||||||
<div className="current-time" style={{ left: `${(this.state.currentTime / this.state.duration) * 100}%` }} />
|
onTap={e => this.handleTap(e)}
|
||||||
<div
|
onPan={e => this.handlePan(e)}
|
||||||
className="cut-start-time"
|
options={{
|
||||||
style={{
|
recognizers: {
|
||||||
left: `${(this.state.cutStartTime / this.state.duration) * 100}%`,
|
},
|
||||||
width: `${((this.state.cutEndTime - this.state.cutStartTime) / this.state.duration) * 100}%`,
|
}}
|
||||||
}}
|
>
|
||||||
|
<div className="timeline-wrapper">
|
||||||
|
<div className="current-time" style={{ left: `${(this.state.currentTime / this.state.duration) * 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}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div id="current-time-display">{formatDuration(this.state.currentTime)}</div>
|
||||||
|
</div>
|
||||||
|
</Hammer>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<i
|
||||||
|
className="button fa fa-step-backward"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => seekAbs(0)}
|
||||||
|
/>
|
||||||
|
<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)}
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
className="button fa fa-step-forward"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => seekAbs(this.state.duration)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div id="current-time-display">{formatDuration(this.state.currentTime)}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
className="jump-cut-start" title="Cut start time"
|
||||||
|
onClick={() => this.jumpCutStart()}
|
||||||
|
>{formatDuration(this.state.cutStartTime || 0)}</button>
|
||||||
|
<i
|
||||||
|
title="Set cut start"
|
||||||
|
className="button fa fa-angle-left"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => this.setCutStart()}
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
title="Export selection"
|
||||||
|
className="button fa fa-scissors"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => this.cutClick()}
|
||||||
|
/>
|
||||||
|
<i
|
||||||
|
title="Set cut end"
|
||||||
|
className="button fa fa-angle-right"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => this.setCutEnd()}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="jump-cut-end" title="Cut end time"
|
||||||
|
onClick={() => this.jumpCutEnd()}
|
||||||
|
>{formatDuration(this.state.cutEndTime || 0)}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="right-menu">
|
||||||
<i
|
<i
|
||||||
id="seek-start"
|
|
||||||
className="fa fa-step-backward"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => seekAbs(0)}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="play"
|
|
||||||
className={classnames({ fa: true, 'fa-pause': this.state.playing, 'fa-play': !this.state.playing })}
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => this.playCommand()}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="seek-end"
|
|
||||||
className="fa fa-step-forward"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => seekAbs(this.state.duration)}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="short-step"
|
|
||||||
title="Short step"
|
|
||||||
className="fa fa-ellipsis-h"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => shortStep(1)}
|
|
||||||
/>
|
|
||||||
<br />
|
|
||||||
<i
|
|
||||||
id="set-start"
|
|
||||||
title="Set cut start"
|
|
||||||
className="fa fa-angle-left"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => this.setState({ cutStartTime: this.state.currentTime })}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="cut"
|
|
||||||
title="Cut/export"
|
|
||||||
className="fa fa-scissors"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => this.cutClick()}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="set-end"
|
|
||||||
title="Set cut end"
|
|
||||||
className="fa fa-angle-right"
|
|
||||||
aria-hidden="true"
|
|
||||||
onClick={() => this.setState({ cutEndTime: this.state.currentTime })}
|
|
||||||
/>
|
|
||||||
<i
|
|
||||||
id="capture-frame"
|
|
||||||
title="Capture frame"
|
title="Capture frame"
|
||||||
className="fa fa-camera"
|
className="button fa fa-camera"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={() => this.capture()}
|
onClick={() => this.capture()}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user