mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 10:22:31 +01:00
Implement manual input field for cutting range
Format duration with : in GUI (still . in files) Minor improvements Move cut time indicators for more horizontal space
This commit is contained in:
parent
e9edf47b17
commit
17ee2798ec
@ -23,7 +23,7 @@ async function captureFrame(customOutDir, filePath, video, currentTime, captureF
|
|||||||
const buf = getFrameFromVideo(video, captureFormat);
|
const buf = getFrameFromVideo(video, captureFormat);
|
||||||
|
|
||||||
const ext = mime.extension(buf.mimetype);
|
const ext = mime.extension(buf.mimetype);
|
||||||
const time = util.formatDuration(currentTime);
|
const time = util.formatDuration(currentTime, true);
|
||||||
|
|
||||||
const outPath = util.getOutPath(customOutDir, filePath, `${time}.${ext}`);
|
const outPath = util.getOutPath(customOutDir, filePath, `${time}.${ext}`);
|
||||||
await fs.writeFileAsync(outPath, buf);
|
await fs.writeFileAsync(outPath, buf);
|
||||||
|
@ -59,7 +59,7 @@ async function cut({
|
|||||||
customOutDir, filePath, format, cutFrom, cutTo, videoDuration, rotation, onProgress,
|
customOutDir, filePath, format, cutFrom, cutTo, videoDuration, rotation, onProgress,
|
||||||
}) {
|
}) {
|
||||||
const ext = path.extname(filePath) || `.${format}`;
|
const ext = path.extname(filePath) || `.${format}`;
|
||||||
const cutSpecification = `${util.formatDuration(cutFrom)}-${util.formatDuration(cutTo)}`;
|
const cutSpecification = `${util.formatDuration(cutFrom, true)}-${util.formatDuration(cutTo, true)}`;
|
||||||
|
|
||||||
const outPath = util.getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`);
|
const outPath = util.getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`);
|
||||||
|
|
||||||
|
12
src/main.css
12
src/main.css
@ -36,17 +36,21 @@ input, button, textarea, :focus {
|
|||||||
|
|
||||||
.button {
|
.button {
|
||||||
padding: .4em;
|
padding: .4em;
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-wrapper button, .right-menu button, .left-menu button {
|
.controls-wrapper button, .right-menu button, .left-menu button, .controls-wrapper input {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: .3em;
|
border-radius: .3em;
|
||||||
color: rgba(0, 0, 0, 0.7);
|
color: rgba(0, 0, 0, 0.7);
|
||||||
font-size: 60%;
|
font-size: 13px;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
padding: .2em .4em;
|
padding: 0 .5em;
|
||||||
margin: 0 .5em;
|
margin: 0 3px;
|
||||||
border: none;
|
border: none;
|
||||||
|
height: 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls-wrapper button:active, .right-menu button:active, .left-menu button:active {
|
.controls-wrapper button:active, .right-menu button:active, .left-menu button:active {
|
||||||
|
100
src/renderer.jsx
100
src/renderer.jsx
@ -62,8 +62,8 @@ function renderHelpSheet(visible) {
|
|||||||
<li><kbd>,</kbd> (comma) Tiny seek backward (1/60 sec)</li>
|
<li><kbd>,</kbd> (comma) Tiny seek backward (1/60 sec)</li>
|
||||||
<li><kbd>I</kbd> Mark in / cut start point</li>
|
<li><kbd>I</kbd> Mark in / cut start point</li>
|
||||||
<li><kbd>O</kbd> Mark out / cut end point</li>
|
<li><kbd>O</kbd> Mark out / cut end point</li>
|
||||||
<li><kbd>E</kbd> Export selection (in the same dir as the video)</li>
|
<li><kbd>E</kbd> Cut (export selection in the same directory)</li>
|
||||||
<li><kbd>C</kbd> Capture snapshot (in the same dir as the video)</li>
|
<li><kbd>C</kbd> Capture snapshot (in the same directory)</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
@ -89,7 +89,9 @@ class App extends React.Component {
|
|||||||
currentTime: undefined,
|
currentTime: undefined,
|
||||||
duration: undefined,
|
duration: undefined,
|
||||||
cutStartTime: 0,
|
cutStartTime: 0,
|
||||||
|
cutStartTimeManual: undefined,
|
||||||
cutEndTime: undefined,
|
cutEndTime: undefined,
|
||||||
|
cutEndTimeManual: undefined,
|
||||||
fileFormat: undefined,
|
fileFormat: undefined,
|
||||||
captureFormat: 'jpeg',
|
captureFormat: 'jpeg',
|
||||||
rotation: 360,
|
rotation: 360,
|
||||||
@ -214,6 +216,14 @@ class App extends React.Component {
|
|||||||
return this.state.rotation !== 360;
|
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() {
|
increaseRotation() {
|
||||||
const rotation = (this.state.rotation + 90) % 450;
|
const rotation = (this.state.rotation + 90) % 450;
|
||||||
this.setState({ rotation });
|
this.setState({ rotation });
|
||||||
@ -278,10 +288,10 @@ class App extends React.Component {
|
|||||||
const filePath = this.state.filePath;
|
const filePath = this.state.filePath;
|
||||||
const rotation = this.isRotationSet() ? this.getRotation() : undefined;
|
const rotation = this.isRotationSet() ? this.getRotation() : undefined;
|
||||||
|
|
||||||
if (cutStartTime === undefined || cutEndTime === undefined) {
|
if (!this.areCutTimesSet()) {
|
||||||
return alert('Please select both start and end time');
|
return alert('Please select both start and end time');
|
||||||
}
|
}
|
||||||
if (cutStartTime >= cutEndTime) {
|
if (!this.isCutRangeValid()) {
|
||||||
return alert('Start time must be before end time');
|
return alert('Start time must be before end time');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,7 +338,44 @@ class App extends React.Component {
|
|||||||
this.setState({ helpVisible: !this.state.helpVisible });
|
this.setState({ helpVisible: !this.state.helpVisible });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
renderCutTimeInput(type) {
|
||||||
|
const cutTimeKey = type === 'start' ? 'cutStartTime' : 'cutEndTime';
|
||||||
|
const cutTimeManualKey = type === 'start' ? 'cutStartTimeManual' : 'cutEndTimeManual';
|
||||||
|
const cutTimeInputStyle = Object.assign({}, { 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 (<input
|
||||||
|
style={Object.assign({}, cutTimeInputStyle, { color: isCutTimeManualSet() ? '#dc1d1d' : undefined })}
|
||||||
|
type="text"
|
||||||
|
onChange={e => handleCutTimeInput(e.target.value)}
|
||||||
|
value={isCutTimeManualSet()
|
||||||
|
? this.state[cutTimeManualKey]
|
||||||
|
: util.formatDuration(this.state[cutTimeKey])
|
||||||
|
}
|
||||||
|
/>);
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
const jumpCutButtonStyle = { position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px' };
|
||||||
|
|
||||||
return (<div>
|
return (<div>
|
||||||
{!this.state.filePath && <div id="drag-drop-field">DROP VIDEO</div>}
|
{!this.state.filePath && <div id="drag-drop-field">DROP VIDEO</div>}
|
||||||
{this.state.working && (
|
{this.state.working && (
|
||||||
@ -374,12 +421,25 @@ class App extends React.Component {
|
|||||||
</div>
|
</div>
|
||||||
</Hammer>
|
</Hammer>
|
||||||
|
|
||||||
<div>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||||
<i
|
<i
|
||||||
className="button fa fa-step-backward"
|
className="button fa fa-step-backward"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
title="Jump to start of video"
|
||||||
onClick={() => seekAbs(0)}
|
onClick={() => seekAbs(0)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{this.renderCutTimeInput('start')}
|
||||||
|
<i
|
||||||
|
style={Object.assign({}, jumpCutButtonStyle, { left: 0 })}
|
||||||
|
className="fa fa-step-backward"
|
||||||
|
title="Jump to cut start"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={withBlur(() => this.jumpCutStart())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<i
|
<i
|
||||||
className="button fa fa-caret-left"
|
className="button fa fa-caret-left"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
@ -395,44 +455,50 @@ class App extends React.Component {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={() => shortStep(1)}
|
onClick={() => shortStep(1)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
{this.renderCutTimeInput('end')}
|
||||||
|
<i
|
||||||
|
style={Object.assign({}, jumpCutButtonStyle, { right: 0 })}
|
||||||
|
className="fa fa-step-forward"
|
||||||
|
title="Jump to cut end"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={withBlur(() => this.jumpCutEnd())}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<i
|
<i
|
||||||
className="button fa fa-step-forward"
|
className="button fa fa-step-forward"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
|
title="Jump to end of video"
|
||||||
onClick={() => seekAbs(this.state.duration)}
|
onClick={() => seekAbs(this.state.duration)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
|
||||||
className="jump-cut-start" title="Cut start time (jump)"
|
|
||||||
onClick={withBlur(() => this.jumpCutStart())}
|
|
||||||
>{util.formatDuration(this.state.cutStartTime || 0)}</button>
|
|
||||||
<i
|
<i
|
||||||
title="Set cut start"
|
title="Set cut start to current position"
|
||||||
className="button fa fa-angle-left"
|
className="button fa fa-angle-left"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={() => this.setCutStart()}
|
onClick={() => this.setCutStart()}
|
||||||
/>
|
/>
|
||||||
<i
|
<i
|
||||||
title="Export selection"
|
title="Cut"
|
||||||
className="button fa fa-scissors"
|
className="button fa fa-scissors"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={() => this.cutClick()}
|
onClick={() => this.cutClick()}
|
||||||
/>
|
/>
|
||||||
<i
|
<i
|
||||||
title="Set cut end"
|
title="Set cut end to current position"
|
||||||
className="button fa fa-angle-right"
|
className="button fa fa-angle-right"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
onClick={() => this.setCutEnd()}
|
onClick={() => this.setCutEnd()}
|
||||||
/>
|
/>
|
||||||
<button
|
|
||||||
className="jump-cut-end" title="Cut end time (jump)"
|
|
||||||
onClick={withBlur(() => this.jumpCutEnd())}
|
|
||||||
>{util.formatDuration(this.state.cutEndTime || 0)}</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="left-menu">
|
<div className="left-menu">
|
||||||
<button title="Format">
|
<button title="Format of current file">
|
||||||
{this.state.fileFormat || 'FMT'}
|
{this.state.fileFormat || 'FMT'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
19
src/util.js
19
src/util.js
@ -2,7 +2,7 @@ const _ = require('lodash');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
function formatDuration(_seconds) {
|
function formatDuration(_seconds, fileNameFriendly) {
|
||||||
const seconds = _seconds || 0;
|
const seconds = _seconds || 0;
|
||||||
const minutes = seconds / 60;
|
const minutes = seconds / 60;
|
||||||
const hours = minutes / 60;
|
const hours = minutes / 60;
|
||||||
@ -13,7 +13,21 @@ function formatDuration(_seconds) {
|
|||||||
const msPadded = _.padStart(Math.floor((seconds - Math.floor(seconds)) * 1000), 3, '0');
|
const msPadded = _.padStart(Math.floor((seconds - Math.floor(seconds)) * 1000), 3, '0');
|
||||||
|
|
||||||
// Be nice to filenames and use .
|
// Be nice to filenames and use .
|
||||||
return `${hoursPadded}.${minutesPadded}.${secondsPadded}.${msPadded}`;
|
const delim = fileNameFriendly ? '.' : ':';
|
||||||
|
return `${hoursPadded}${delim}${minutesPadded}${delim}${secondsPadded}.${msPadded}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDuration(str) {
|
||||||
|
if (!str) return undefined;
|
||||||
|
const match = str.trim().match(/^(\d{2}):(\d{2}):(\d{2})\.(\d{3})$/);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const hours = parseInt(match[1], 10);
|
||||||
|
const minutes = parseInt(match[2], 10);
|
||||||
|
const seconds = parseInt(match[3], 10);
|
||||||
|
const ms = parseInt(match[4], 10);
|
||||||
|
if (hours > 59 || minutes > 59 || seconds > 59) return undefined;
|
||||||
|
|
||||||
|
return ((((hours * 60) + minutes) * 60) + seconds) + (ms / 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOutPath(customOutDir, filePath, nameSuffix) {
|
function getOutPath(customOutDir, filePath, nameSuffix) {
|
||||||
@ -45,6 +59,7 @@ async function transferTimestampsWithOffset(inPath, outPath, offset) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
|
parseDuration,
|
||||||
getOutPath,
|
getOutPath,
|
||||||
transferTimestamps,
|
transferTimestamps,
|
||||||
transferTimestampsWithOffset,
|
transferTimestampsWithOffset,
|
||||||
|
Loading…
Reference in New Issue
Block a user