mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-21 18:02:35 +01:00
parent
2a44d0b648
commit
0051e4d289
@ -9,6 +9,7 @@
|
||||
"no-console": 0,
|
||||
"react/destructuring-assignment": 0,
|
||||
"react/forbid-prop-types": [1, { "forbid": ["any"] }],
|
||||
"jsx-a11y/click-events-have-key-events": 0,
|
||||
},
|
||||
"plugins": [
|
||||
"react"
|
||||
|
@ -8,6 +8,7 @@ Simple and ultra fast cross platform tool for lossless trimming/cutting of video
|
||||
- Lossless cutting of common video and audio formats
|
||||
- Lossless merge of files (identical codecs)
|
||||
- Lossless extracting of all streams from a file (video, audio, subtitle, ++)
|
||||
- Cut out multiple segments at the same time
|
||||
- Take full-resolution snapshots from videos in JPEG/PNG format
|
||||
- Manual input range of cutpoints
|
||||
- Can include more than 2 streams or remove audio track (optional)
|
||||
|
@ -54,6 +54,7 @@
|
||||
"dependencies": {
|
||||
"bluebird": "^3.4.6",
|
||||
"classnames": "^2.2.5",
|
||||
"color": "^3.1.0",
|
||||
"electron": "^2.0.9",
|
||||
"electron-default-menu": "^1.0.0",
|
||||
"electron-is-dev": "^0.1.2",
|
||||
@ -77,6 +78,7 @@
|
||||
"sweetalert2": "^8.0.1",
|
||||
"sweetalert2-react-content": "^1.0.1",
|
||||
"trash": "^4.3.0",
|
||||
"uuid": "^3.3.2",
|
||||
"which": "^1.2.11"
|
||||
}
|
||||
}
|
||||
|
@ -20,6 +20,8 @@ const HelpSheet = ({ visible }) => {
|
||||
<li><kbd>O</kbd> Mark out / cut end point</li>
|
||||
<li><kbd>E</kbd> Cut (export selection in the same directory)</li>
|
||||
<li><kbd>C</kbd> Capture snapshot (in the same directory)</li>
|
||||
<li><kbd>+</kbd> Add cut segment</li>
|
||||
<li><kbd>BACKSPACE</kbd> Remove current cut segment</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
83
src/TimelineSeg.jsx
Normal file
83
src/TimelineSeg.jsx
Normal file
@ -0,0 +1,83 @@
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
const TimelineSeg = ({
|
||||
isCutRangeValid, duration: durationRaw, cutStartTime, cutEndTime, apparentCutStart,
|
||||
apparentCutEnd, isActive, segNum, onSegClick, color,
|
||||
}) => {
|
||||
const markerWidth = 4;
|
||||
const duration = durationRaw || 1;
|
||||
const cutSectionWidth = `calc(${((apparentCutEnd - apparentCutStart) / duration) * 100}% - ${markerWidth * 2}px)`;
|
||||
|
||||
const startTimePos = `${(apparentCutStart / duration) * 100}%`;
|
||||
const endTimePos = `${(apparentCutEnd / duration) * 100}%`;
|
||||
const markerBorder = isActive ? `2px solid ${color.string()}` : undefined;
|
||||
const markerBorderRadius = 5;
|
||||
|
||||
const startMarkerStyle = {
|
||||
background: color.alpha(0.5).string(),
|
||||
width: markerWidth,
|
||||
left: startTimePos,
|
||||
borderLeft: markerBorder,
|
||||
borderTopLeftRadius: markerBorderRadius,
|
||||
borderBottomLeftRadius: markerBorderRadius,
|
||||
};
|
||||
const endMarkerStyle = {
|
||||
background: color.alpha(0.5).string(),
|
||||
width: markerWidth,
|
||||
marginLeft: -markerWidth,
|
||||
left: endTimePos,
|
||||
borderRight: markerBorder,
|
||||
borderTopRightRadius: markerBorderRadius,
|
||||
borderBottomRightRadius: markerBorderRadius,
|
||||
};
|
||||
const cutSectionStyle = {
|
||||
background: color.alpha(0.5).string(),
|
||||
marginLeft: markerWidth,
|
||||
left: startTimePos,
|
||||
width: cutSectionWidth,
|
||||
};
|
||||
|
||||
const onThisSegClick = () => onSegClick(segNum);
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{cutStartTime !== undefined && (
|
||||
<div style={startMarkerStyle} className="cut-time-marker" role="button" tabIndex="0" onClick={onThisSegClick} />
|
||||
)}
|
||||
{isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && (
|
||||
<div
|
||||
className="cut-section"
|
||||
style={cutSectionStyle}
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={onThisSegClick}
|
||||
/>
|
||||
)}
|
||||
{cutEndTime !== undefined && (
|
||||
<div style={endMarkerStyle} className="cut-time-marker" role="button" tabIndex="0" onClick={onThisSegClick} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
TimelineSeg.propTypes = {
|
||||
isCutRangeValid: PropTypes.bool.isRequired,
|
||||
duration: PropTypes.number,
|
||||
cutStartTime: PropTypes.number,
|
||||
cutEndTime: PropTypes.number,
|
||||
apparentCutStart: PropTypes.number.isRequired,
|
||||
apparentCutEnd: PropTypes.number.isRequired,
|
||||
isActive: PropTypes.bool.isRequired,
|
||||
segNum: PropTypes.number.isRequired,
|
||||
onSegClick: PropTypes.func.isRequired,
|
||||
color: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
TimelineSeg.defaultProps = {
|
||||
duration: undefined,
|
||||
cutStartTime: undefined,
|
||||
cutEndTime: undefined,
|
||||
};
|
||||
|
||||
module.exports = TimelineSeg;
|
@ -5,6 +5,7 @@ const path = require('path');
|
||||
const fileType = require('file-type');
|
||||
const readChunk = require('read-chunk');
|
||||
const flatMap = require('lodash/flatMap');
|
||||
const sum = require('lodash/sum');
|
||||
const readline = require('readline');
|
||||
const moment = require('moment');
|
||||
const stringToStream = require('string-to-stream');
|
||||
@ -110,6 +111,39 @@ async function cut({
|
||||
await transferTimestamps(filePath, outPath);
|
||||
}
|
||||
|
||||
async function cutMultiple({
|
||||
customOutDir, filePath, format, segments, videoDuration, rotation,
|
||||
includeAllStreams, onProgress, stripAudio, keyframeCut,
|
||||
}) {
|
||||
const singleProgresses = {};
|
||||
function onSingleProgress(id, singleProgress) {
|
||||
singleProgresses[id] = singleProgress;
|
||||
return onProgress((sum(Object.values(singleProgresses)) / segments.length));
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const { cutFrom, cutTo, cutToApparent } of segments) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await cut({
|
||||
customOutDir,
|
||||
filePath,
|
||||
format,
|
||||
videoDuration,
|
||||
rotation,
|
||||
includeAllStreams,
|
||||
stripAudio,
|
||||
keyframeCut,
|
||||
cutFrom,
|
||||
cutTo,
|
||||
cutToApparent,
|
||||
// eslint-disable-next-line no-loop-func
|
||||
onProgress: progress => onSingleProgress(i, progress),
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async function html5ify(filePath, outPath, encodeVideo) {
|
||||
console.log('Making HTML5 friendly version', { filePath, outPath, encodeVideo });
|
||||
|
||||
@ -274,7 +308,7 @@ async function extractAllStreams(filePath) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cut,
|
||||
cutMultiple,
|
||||
getFormat,
|
||||
html5ify,
|
||||
mergeFiles,
|
||||
|
@ -35,6 +35,7 @@ input, button, textarea, :focus {
|
||||
}
|
||||
|
||||
.button {
|
||||
border-radius: 3px;
|
||||
padding: .4em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@ -102,12 +103,10 @@ input, button, textarea, :focus {
|
||||
|
||||
.timeline-wrapper .cut-section {
|
||||
z-index: 1;
|
||||
background-color: rgba(0, 255, 149, 0.5);
|
||||
}
|
||||
.timeline-wrapper .cut-time-marker {
|
||||
z-index: 2;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 255, 149, 0.5);
|
||||
}
|
||||
.timeline-wrapper .current-time {
|
||||
z-index: 3;
|
||||
|
26
src/random-color.js
Normal file
26
src/random-color.js
Normal file
@ -0,0 +1,26 @@
|
||||
// https://github.com/mock-end/random-color/blob/master/index.js
|
||||
/* eslint-disable */
|
||||
|
||||
const color = require('color');
|
||||
|
||||
var ratio = 0.618033988749895;
|
||||
var hue = 0.65;
|
||||
|
||||
module.exports = function (saturation, value) {
|
||||
hue += ratio;
|
||||
hue %= 1;
|
||||
|
||||
if (typeof saturation !== 'number') {
|
||||
saturation = 0.5;
|
||||
}
|
||||
|
||||
if (typeof value !== 'number') {
|
||||
value = 0.95;
|
||||
}
|
||||
|
||||
return color({
|
||||
h: hue * 360,
|
||||
s: saturation * 100,
|
||||
v: value * 100,
|
||||
});
|
||||
};
|
272
src/renderer.jsx
272
src/renderer.jsx
@ -3,16 +3,19 @@ const $ = require('jquery');
|
||||
const Mousetrap = require('mousetrap');
|
||||
const round = require('lodash/round');
|
||||
const clamp = require('lodash/clamp');
|
||||
const clone = require('lodash/clone');
|
||||
const throttle = require('lodash/throttle');
|
||||
const Hammer = require('react-hammerjs').default;
|
||||
const path = require('path');
|
||||
const trash = require('trash');
|
||||
const uuid = require('uuid');
|
||||
|
||||
const React = require('react');
|
||||
const ReactDOM = require('react-dom');
|
||||
const classnames = require('classnames');
|
||||
|
||||
const HelpSheet = require('./HelpSheet');
|
||||
const TimelineSeg = require('./TimelineSeg');
|
||||
const { showMergeDialog } = require('./merge/merge');
|
||||
|
||||
const captureFrame = require('./capture-frame');
|
||||
@ -21,7 +24,7 @@ const ffmpeg = require('./ffmpeg');
|
||||
|
||||
const {
|
||||
getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
|
||||
promptTimeOffset,
|
||||
promptTimeOffset, generateColor,
|
||||
} = require('./util');
|
||||
|
||||
const { dialog } = electron.remote;
|
||||
@ -60,23 +63,31 @@ function withBlur(cb) {
|
||||
};
|
||||
}
|
||||
|
||||
function createSegment({ start, end } = {}) {
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
color: generateColor(),
|
||||
uuid: uuid.v4(),
|
||||
};
|
||||
}
|
||||
|
||||
const localState = {
|
||||
const getInitialLocalState = () => ({
|
||||
working: false,
|
||||
filePath: '', // Setting video src="" prevents memory leak in chromium
|
||||
html5FriendlyPath: undefined,
|
||||
playing: false,
|
||||
currentTime: undefined,
|
||||
duration: undefined,
|
||||
cutStartTime: undefined,
|
||||
cutSegments: [createSegment()],
|
||||
currentSeg: 0,
|
||||
cutStartTimeManual: undefined,
|
||||
cutEndTime: undefined,
|
||||
cutEndTimeManual: undefined,
|
||||
fileFormat: undefined,
|
||||
rotation: 360,
|
||||
cutProgress: undefined,
|
||||
startTimeOffset: 0,
|
||||
};
|
||||
});
|
||||
|
||||
const globalState = {
|
||||
stripAudio: false,
|
||||
@ -91,7 +102,7 @@ class App extends React.Component {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
...localState,
|
||||
...getInitialLocalState(),
|
||||
...globalState,
|
||||
};
|
||||
|
||||
@ -220,6 +231,8 @@ class App extends React.Component {
|
||||
Mousetrap.bind('i', () => this.setCutStart());
|
||||
Mousetrap.bind('o', () => this.setCutEnd());
|
||||
Mousetrap.bind('h', () => this.toggleHelp());
|
||||
Mousetrap.bind('+', () => this.addCutSegment());
|
||||
Mousetrap.bind('backspace', () => this.removeCutSegment());
|
||||
|
||||
electron.ipcRenderer.send('renderer-ready');
|
||||
}
|
||||
@ -241,11 +254,13 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
setCutStart = () => {
|
||||
this.setState(({ currentTime }) => ({ cutStartTime: currentTime }));
|
||||
const { currentTime } = this.state;
|
||||
this.setCutTime('start', currentTime);
|
||||
}
|
||||
|
||||
setCutEnd = () => {
|
||||
this.setState(({ currentTime }) => ({ cutEndTime: currentTime }));
|
||||
const { currentTime } = this.state;
|
||||
this.setCutTime('end', currentTime);
|
||||
}
|
||||
|
||||
setOutputDir = () => {
|
||||
@ -274,13 +289,35 @@ class App extends React.Component {
|
||||
return `${this.getRotation()}°`;
|
||||
}
|
||||
|
||||
getApparentCutStartTime() {
|
||||
if (this.state.cutStartTime !== undefined) return this.state.cutStartTime;
|
||||
getCutSeg(i) {
|
||||
const { currentSeg, cutSegments } = this.state;
|
||||
return cutSegments[i !== undefined ? i : currentSeg];
|
||||
}
|
||||
|
||||
getCutStartTime(i) {
|
||||
return this.getCutSeg(i).start;
|
||||
}
|
||||
|
||||
getCutEndTime(i) {
|
||||
return this.getCutSeg(i).end;
|
||||
}
|
||||
|
||||
setCutTime(type, time) {
|
||||
const { currentSeg, cutSegments } = this.state;
|
||||
const cloned = clone(cutSegments);
|
||||
cloned[currentSeg][type] = time;
|
||||
this.setState({ cutSegments: cloned });
|
||||
}
|
||||
|
||||
getApparentCutStartTime(i) {
|
||||
const cutStartTime = this.getCutStartTime(i);
|
||||
if (cutStartTime !== undefined) return cutStartTime;
|
||||
return 0;
|
||||
}
|
||||
|
||||
getApparentCutEndTime() {
|
||||
if (this.state.cutEndTime !== undefined) return this.state.cutEndTime;
|
||||
getApparentCutEndTime(i) {
|
||||
const cutEndTime = this.getCutEndTime(i);
|
||||
if (cutEndTime !== undefined) return cutEndTime;
|
||||
if (this.state.duration !== undefined) return this.state.duration;
|
||||
return 0; // Haven't gotten duration yet
|
||||
}
|
||||
@ -306,6 +343,41 @@ class App extends React.Component {
|
||||
|
||||
toggleKeyframeCut = () => this.setState(({ keyframeCut }) => ({ keyframeCut: !keyframeCut }));
|
||||
|
||||
addCutSegment = () => {
|
||||
const { cutSegments, currentTime, duration } = this.state;
|
||||
|
||||
const cutStartTime = this.getCutStartTime();
|
||||
const cutEndTime = this.getCutEndTime();
|
||||
|
||||
if (cutStartTime === undefined && cutEndTime === undefined) return;
|
||||
|
||||
const suggestedStart = currentTime;
|
||||
const suggestedEnd = suggestedStart + 10;
|
||||
|
||||
const cutSegmentsNew = [
|
||||
...cutSegments,
|
||||
createSegment({
|
||||
start: currentTime,
|
||||
end: suggestedEnd <= duration ? suggestedEnd : undefined,
|
||||
}),
|
||||
];
|
||||
|
||||
const currentSegNew = cutSegmentsNew.length - 1;
|
||||
this.setState({ currentSeg: currentSegNew, cutSegments: cutSegmentsNew });
|
||||
}
|
||||
|
||||
removeCutSegment = () => {
|
||||
const { currentSeg, cutSegments } = this.state;
|
||||
|
||||
if (cutSegments.length < 2) return;
|
||||
|
||||
const cutSegmentsNew = [...cutSegments];
|
||||
cutSegmentsNew.splice(currentSeg, 1);
|
||||
|
||||
const currentSegNew = Math.min(currentSeg, cutSegmentsNew.length - 1);
|
||||
this.setState({ currentSeg: currentSegNew, cutSegments: cutSegmentsNew });
|
||||
}
|
||||
|
||||
jumpCutStart = () => {
|
||||
seekAbs(this.getApparentCutStartTime());
|
||||
}
|
||||
@ -350,37 +422,45 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
cutClick = async () => {
|
||||
if (this.state.working) {
|
||||
const {
|
||||
filePath, customOutDir, fileFormat, duration, includeAllStreams,
|
||||
stripAudio, keyframeCut, working, cutSegments,
|
||||
} = this.state;
|
||||
|
||||
if (working) {
|
||||
errorToast('I\'m busy');
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
cutEndTime, cutStartTime, filePath, customOutDir, fileFormat, duration, includeAllStreams,
|
||||
stripAudio, keyframeCut,
|
||||
} = this.state;
|
||||
|
||||
const rotation = this.isRotationSet() ? this.getRotation() : undefined;
|
||||
|
||||
const cutStartTime = this.getCutStartTime();
|
||||
const cutEndTime = this.getCutEndTime();
|
||||
|
||||
if (!(this.isCutRangeValid() || cutEndTime === undefined || cutStartTime === undefined)) {
|
||||
errorToast('Start time must be before end time');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ working: true });
|
||||
try {
|
||||
await ffmpeg.cut({
|
||||
this.setState({ working: true });
|
||||
|
||||
const segments = cutSegments.map((seg, i) => ({
|
||||
cutFrom: this.getApparentCutStartTime(i),
|
||||
cutTo: this.getCutEndTime(i),
|
||||
cutToApparent: this.getApparentCutEndTime(i),
|
||||
}));
|
||||
|
||||
await ffmpeg.cutMultiple({
|
||||
customOutDir,
|
||||
filePath,
|
||||
format: fileFormat,
|
||||
cutFrom: this.getApparentCutStartTime(),
|
||||
cutTo: cutEndTime,
|
||||
cutToApparent: this.getApparentCutEndTime(),
|
||||
videoDuration: duration,
|
||||
rotation,
|
||||
includeAllStreams,
|
||||
stripAudio,
|
||||
keyframeCut,
|
||||
segments,
|
||||
onProgress: this.onCutProgress,
|
||||
});
|
||||
} catch (err) {
|
||||
@ -425,7 +505,7 @@ class App extends React.Component {
|
||||
const video = getVideo();
|
||||
video.currentTime = 0;
|
||||
video.playbackRate = 1;
|
||||
this.setState(localState);
|
||||
this.setState(getInitialLocalState());
|
||||
setFileNameTitle();
|
||||
}
|
||||
|
||||
@ -434,8 +514,8 @@ class App extends React.Component {
|
||||
return this.state.rotation !== 360;
|
||||
}
|
||||
|
||||
isCutRangeValid() {
|
||||
return this.getApparentCutStartTime() < this.getApparentCutEndTime();
|
||||
isCutRangeValid(i) {
|
||||
return this.getApparentCutStartTime(i) < this.getApparentCutEndTime(i);
|
||||
}
|
||||
|
||||
toggleHelp() {
|
||||
@ -461,11 +541,9 @@ class App extends React.Component {
|
||||
return;
|
||||
}
|
||||
|
||||
const cutTimeKey = type === 'start' ? 'cutStartTime' : 'cutEndTime';
|
||||
this.setState(state => ({
|
||||
[cutTimeManualKey]: undefined,
|
||||
[cutTimeKey]: time - state.startTimeOffset,
|
||||
}));
|
||||
this.setState({ [cutTimeManualKey]: undefined });
|
||||
|
||||
this.setCutTime(type, time - this.state.startTimeOffset);
|
||||
};
|
||||
|
||||
const cutTime = type === 'start' ? this.getApparentCutStartTime() : this.getApparentCutEndTime();
|
||||
@ -484,6 +562,18 @@ class App extends React.Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
working, filePath, duration: durationRaw, cutProgress, currentTime, playing,
|
||||
fileFormat, playbackRate, keyframeCut, includeAllStreams, stripAudio, captureFormat,
|
||||
helpVisible, currentSeg, cutSegments,
|
||||
} = this.state;
|
||||
|
||||
const duration = durationRaw || 1;
|
||||
const currentTimePos = currentTime !== undefined && `${(currentTime / duration) * 100}%`;
|
||||
|
||||
const segColor = this.getCutSeg().color;
|
||||
const segBgColor = segColor.alpha(0.5).string();
|
||||
|
||||
const jumpCutButtonStyle = {
|
||||
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
|
||||
};
|
||||
@ -491,42 +581,6 @@ class App extends React.Component {
|
||||
background: 'rgba(255, 255, 255, 0.4)', padding: '.1em .4em', margin: '0 3px', fontSize: 13, borderRadius: '.3em',
|
||||
};
|
||||
|
||||
const {
|
||||
working, filePath, duration: durationRaw, cutProgress, currentTime, playing,
|
||||
fileFormat, playbackRate, keyframeCut, includeAllStreams, stripAudio, captureFormat,
|
||||
helpVisible, cutStartTime, cutEndTime,
|
||||
} = this.state;
|
||||
|
||||
const markerWidth = 4;
|
||||
const apparentCutStart = this.getApparentCutStartTime();
|
||||
const apprentCutEnd = this.getApparentCutEndTime();
|
||||
const duration = durationRaw || 1;
|
||||
const currentTimePos = currentTime !== undefined && `${(currentTime / duration) * 100}%`;
|
||||
const cutSectionWidth = `calc(${((apprentCutEnd - apparentCutStart) / duration) * 100}% - ${markerWidth * 2}px)`;
|
||||
|
||||
const isCutRangeValid = this.isCutRangeValid();
|
||||
|
||||
const startTimePos = `${(apparentCutStart / duration) * 100}%`;
|
||||
const endTimePos = `${(apprentCutEnd / duration) * 100}%`;
|
||||
const markerBorder = '2px solid rgb(0, 255, 149)';
|
||||
const markerBorderRadius = 5;
|
||||
|
||||
const startMarkerStyle = {
|
||||
width: markerWidth,
|
||||
left: startTimePos,
|
||||
borderLeft: markerBorder,
|
||||
borderTopLeftRadius: markerBorderRadius,
|
||||
borderBottomLeftRadius: markerBorderRadius,
|
||||
};
|
||||
const endMarkerStyle = {
|
||||
width: markerWidth,
|
||||
marginLeft: -markerWidth,
|
||||
left: endTimePos,
|
||||
borderRight: markerBorder,
|
||||
borderTopRightRadius: markerBorderRadius,
|
||||
borderBottomRightRadius: markerBorderRadius,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!filePath && (
|
||||
@ -571,18 +625,21 @@ class App extends React.Component {
|
||||
<div className="timeline-wrapper">
|
||||
{currentTimePos !== undefined && <div className="current-time" style={{ left: currentTimePos }} />}
|
||||
|
||||
{cutStartTime !== undefined && <div style={startMarkerStyle} className="cut-time-marker" />}
|
||||
{isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && (
|
||||
<div
|
||||
className="cut-section"
|
||||
style={{
|
||||
marginLeft: markerWidth,
|
||||
left: startTimePos,
|
||||
width: cutSectionWidth,
|
||||
}}
|
||||
{cutSegments.map((seg, i) => (
|
||||
<TimelineSeg
|
||||
key={seg.uuid}
|
||||
segNum={i}
|
||||
color={seg.color}
|
||||
onSegClick={currentSegNew => this.setState({ currentSeg: currentSegNew })}
|
||||
isActive={i === currentSeg}
|
||||
isCutRangeValid={this.isCutRangeValid(i)}
|
||||
duration={duration}
|
||||
cutStartTime={this.getCutStartTime(i)}
|
||||
cutEndTime={this.getCutEndTime(i)}
|
||||
apparentCutStart={this.getApparentCutStartTime(i)}
|
||||
apparentCutEnd={this.getApparentCutEndTime(i)}
|
||||
/>
|
||||
)}
|
||||
{cutEndTime !== undefined && <div style={endMarkerStyle} className="cut-time-marker" />}
|
||||
))}
|
||||
|
||||
<div id="current-time-display">{formatDuration(this.getOffsetCurrentTime())}</div>
|
||||
</div>
|
||||
@ -591,7 +648,8 @@ class App extends React.Component {
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<i
|
||||
className="button fa fa-step-backward"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
title="Jump to start of video"
|
||||
onClick={() => seekAbs(0)}
|
||||
/>
|
||||
@ -602,26 +660,30 @@ class App extends React.Component {
|
||||
style={{ ...jumpCutButtonStyle, left: 0 }}
|
||||
className="fa fa-step-backward"
|
||||
title="Jump to cut start"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={withBlur(this.jumpCutStart)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<i
|
||||
className="button fa fa-caret-left"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={() => shortStep(-1)}
|
||||
/>
|
||||
<i
|
||||
className={classnames({
|
||||
button: true, fa: true, 'fa-pause': playing, 'fa-play': !playing,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.playCommand}
|
||||
/>
|
||||
<i
|
||||
className="button fa fa-caret-right"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={() => shortStep(1)}
|
||||
/>
|
||||
|
||||
@ -631,14 +693,16 @@ class App extends React.Component {
|
||||
style={{ ...jumpCutButtonStyle, right: 0 }}
|
||||
className="fa fa-step-forward"
|
||||
title="Jump to cut end"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={withBlur(this.jumpCutEnd)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<i
|
||||
className="button fa fa-step-forward"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
title="Jump to end of video"
|
||||
onClick={() => seekAbs(duration)}
|
||||
/>
|
||||
@ -646,27 +710,33 @@ class App extends React.Component {
|
||||
|
||||
<div>
|
||||
<i
|
||||
style={{ background: segBgColor }}
|
||||
title="Set cut start to current position"
|
||||
className="button fa fa-angle-left"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.setCutStart}
|
||||
/>
|
||||
<i
|
||||
title="Cut"
|
||||
title={cutSegments.length > 1 ? 'Export all segments' : 'Export selection'}
|
||||
className="button fa fa-scissors"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.cutClick}
|
||||
/>
|
||||
<i
|
||||
title="Delete source file"
|
||||
className="button fa fa-trash"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.deleteSourceClick}
|
||||
/>
|
||||
<i
|
||||
style={{ background: segBgColor }}
|
||||
title="Set cut end to current position"
|
||||
className="button fa fa-angle-right"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.setCutEnd}
|
||||
/>
|
||||
</div>
|
||||
@ -680,6 +750,25 @@ class App extends React.Component {
|
||||
<span style={infoSpanStyle} title="Playback rate">
|
||||
{round(playbackRate, 1) || 1}
|
||||
</span>
|
||||
|
||||
<button
|
||||
style={{ ...infoSpanStyle, background: segBgColor, color: 'white' }}
|
||||
disabled={cutSegments.length < 2}
|
||||
type="button"
|
||||
title={`Delete selected segment ${currentSeg + 1}`}
|
||||
onClick={withBlur(() => this.removeCutSegment())}
|
||||
>
|
||||
d
|
||||
{currentSeg + 1}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title={`Add cut segment ${currentSeg + 1}`}
|
||||
onClick={withBlur(() => this.addCutSegment())}
|
||||
>
|
||||
c+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="right-menu">
|
||||
@ -727,7 +816,8 @@ class App extends React.Component {
|
||||
title="Capture frame"
|
||||
style={{ margin: '-.4em -.2em' }}
|
||||
className="button fa fa-camera"
|
||||
aria-hidden="true"
|
||||
role="button"
|
||||
tabIndex="0"
|
||||
onClick={this.capture}
|
||||
/>
|
||||
|
||||
|
@ -3,6 +3,9 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const swal = require('sweetalert2');
|
||||
|
||||
const randomColor = require('./random-color');
|
||||
|
||||
|
||||
function formatDuration(_seconds, fileNameFriendly) {
|
||||
const seconds = _seconds || 0;
|
||||
const minutes = seconds / 60;
|
||||
@ -101,6 +104,10 @@ async function promptTimeOffset(inputValue) {
|
||||
return duration;
|
||||
}
|
||||
|
||||
function generateColor() {
|
||||
return randomColor(1, 0.95);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDuration,
|
||||
parseDuration,
|
||||
@ -112,4 +119,5 @@ module.exports = {
|
||||
showFfmpegFail,
|
||||
setFileNameTitle,
|
||||
promptTimeOffset,
|
||||
generateColor,
|
||||
};
|
||||
|
40
yarn.lock
40
yarn.lock
@ -1287,7 +1287,7 @@ code-point-at@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/code-point-at/-/code-point-at-1.1.0.tgz#0d070b4d043a5bea33a2f1a40e2edb3d9a4ccf77"
|
||||
integrity sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=
|
||||
|
||||
color-convert@^1.9.0:
|
||||
color-convert@^1.9.0, color-convert@^1.9.1:
|
||||
version "1.9.3"
|
||||
resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8"
|
||||
integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==
|
||||
@ -1299,6 +1299,27 @@ color-name@1.1.3:
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
|
||||
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
|
||||
|
||||
color-name@^1.0.0:
|
||||
version "1.1.4"
|
||||
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
|
||||
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
|
||||
|
||||
color-string@^1.5.2:
|
||||
version "1.5.3"
|
||||
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.5.3.tgz#c9bbc5f01b58b5492f3d6857459cb6590ce204cc"
|
||||
integrity sha512-dC2C5qeWoYkxki5UAXapdjqO672AM4vZuPGRQfO8b5HKuKGBbKWpITyDYN7TOFKvRW7kOgAn3746clDBMDJyQw==
|
||||
dependencies:
|
||||
color-name "^1.0.0"
|
||||
simple-swizzle "^0.2.2"
|
||||
|
||||
color@^3.1.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/color/-/color-3.1.0.tgz#d8e9fb096732875774c84bf922815df0308d0ffc"
|
||||
integrity sha512-CwyopLkuRYO5ei2EpzpIh6LqJMt6Mt+jZhO5VI5f/wJLZriXQE32/SSqzmrh+QB+AZT81Cj8yv+7zwToW8ahZg==
|
||||
dependencies:
|
||||
color-convert "^1.9.1"
|
||||
color-string "^1.5.2"
|
||||
|
||||
combined-stream@1.0.6:
|
||||
version "1.0.6"
|
||||
resolved "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz#723e7df6e801ac5613113a7e445a9b69cb632818"
|
||||
@ -2713,6 +2734,11 @@ is-arrayish@^0.2.1:
|
||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
|
||||
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
|
||||
|
||||
is-arrayish@^0.3.1:
|
||||
version "0.3.2"
|
||||
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
|
||||
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
|
||||
|
||||
is-binary-path@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-1.0.1.tgz#75f16642b480f187a711c814161fd3a4a7655898"
|
||||
@ -4350,6 +4376,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2:
|
||||
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d"
|
||||
integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=
|
||||
|
||||
simple-swizzle@^0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
|
||||
integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
|
||||
dependencies:
|
||||
is-arrayish "^0.3.1"
|
||||
|
||||
single-line-log@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/single-line-log/-/single-line-log-1.1.2.tgz#c2f83f273a3e1a16edb0995661da0ed5ef033364"
|
||||
@ -4857,6 +4890,11 @@ uuid@^3.1.0:
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14"
|
||||
integrity sha512-jZnMwlb9Iku/O3smGWvZhauCf6cvvpKi4BKRiliS3cxnI+Gz9j5MEpTz2UFuXiKPJocb7gnsLHwiS05ige5BEA==
|
||||
|
||||
uuid@^3.3.2:
|
||||
version "3.3.2"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131"
|
||||
integrity sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==
|
||||
|
||||
v8flags@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.1.1.tgz#aab1a1fa30d45f88dd321148875ac02c0b55e5b4"
|
||||
|
Loading…
Reference in New Issue
Block a user