mirror of
https://github.com/mifi/lossless-cut.git
synced 2024-11-22 02:12:30 +01:00
Improvements:
- Make side panel for cut segments - Use up/down key to jump prev/next segment #254
This commit is contained in:
parent
8addb00789
commit
b22654a853
@ -10,6 +10,7 @@
|
||||
"browser": true
|
||||
},
|
||||
"rules": {
|
||||
"max-len": 0,
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "error",
|
||||
|
||||
|
@ -73,6 +73,7 @@
|
||||
"mousetrap": "^1.6.1",
|
||||
"p-map": "^3.0.0",
|
||||
"p-queue": "^6.2.0",
|
||||
"pretty-ms": "^6.0.0",
|
||||
"prop-types": "^15.6.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { memo } from 'react';
|
||||
import { IoIosCloseCircleOutline } from 'react-icons/io';
|
||||
import { FaClipboard } from 'react-icons/fa';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
@ -9,9 +9,8 @@ const { clipboard } = require('electron');
|
||||
|
||||
const { toast } = require('./util');
|
||||
|
||||
const HelpSheet = ({
|
||||
visible, onTogglePress, renderSettings, ffmpegCommandLog, sortedCutSegments,
|
||||
formatTimecode,
|
||||
const HelpSheet = memo(({
|
||||
visible, onTogglePress, renderSettings, ffmpegCommandLog,
|
||||
}) => (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
@ -30,8 +29,8 @@ const HelpSheet = ({
|
||||
<div><kbd>L</kbd> Speed up video</div>
|
||||
<div><kbd>←</kbd> Seek backward 1 sec</div>
|
||||
<div><kbd>→</kbd> Seek forward 1 sec</div>
|
||||
<div><kbd>.</kbd> (period) Tiny seek forward (1/60 sec)</div>
|
||||
<div><kbd>,</kbd> (comma) Tiny seek backward (1/60 sec)</div>
|
||||
<div><kbd>,</kbd> Seek backward 1 frame</div>
|
||||
<div><kbd>.</kbd> Seek forward 1 frame</div>
|
||||
<div><kbd>I</kbd> Mark in / cut start point</div>
|
||||
<div><kbd>O</kbd> Mark out / cut end point</div>
|
||||
<div><kbd>E</kbd> Cut (export selection in the same directory)</div>
|
||||
@ -56,16 +55,6 @@ const HelpSheet = ({
|
||||
</Table.Body>
|
||||
</Table>
|
||||
|
||||
<h1 style={{ marginTop: 40 }}>Segment list</h1>
|
||||
|
||||
<div style={{ overflowY: 'scroll', height: 200 }}>
|
||||
{sortedCutSegments.map((seg) => (
|
||||
<div key={seg.uuid} style={{ margin: '5px 0' }}>
|
||||
{formatTimecode(seg.start)} - {formatTimecode(seg.end)} {seg.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h1 style={{ marginTop: 40 }}>Last ffmpeg commands</h1>
|
||||
<div style={{ overflowY: 'scroll', height: 200 }}>
|
||||
{ffmpegCommandLog.reverse().map((log) => (
|
||||
@ -77,6 +66,6 @@ const HelpSheet = ({
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
));
|
||||
|
||||
export default HelpSheet;
|
||||
|
@ -3,6 +3,7 @@ import { motion } from 'framer-motion';
|
||||
import { FaTrashAlt, FaSave } from 'react-icons/fa';
|
||||
|
||||
import { mySpring } from './animations';
|
||||
import { saveColor } from './colors';
|
||||
|
||||
const InverseCutSegment = ({ seg, duration, invertCutSegments }) => (
|
||||
<motion.div
|
||||
@ -20,7 +21,7 @@ const InverseCutSegment = ({ seg, duration, invertCutSegments }) => (
|
||||
>
|
||||
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
|
||||
{invertCutSegments ? (
|
||||
<FaSave style={{ color: 'hsl(158, 100%, 43%)' }} size={16} />
|
||||
<FaSave style={{ color: saveColor }} size={16} />
|
||||
) : (
|
||||
<FaTrashAlt style={{ color: 'rgba(255, 255, 255, 0.3)' }} size={16} />
|
||||
)}
|
||||
|
69
src/SegmentList.jsx
Normal file
69
src/SegmentList.jsx
Normal file
@ -0,0 +1,69 @@
|
||||
import React, { memo, Fragment } from 'react';
|
||||
import prettyMs from 'pretty-ms';
|
||||
import { FaSave } from 'react-icons/fa';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
import { saveColor } from './colors';
|
||||
|
||||
const SegmentList = memo(({
|
||||
formatTimecode, cutSegments, getFrameCount, getSegColors, onSegClick,
|
||||
currentSegIndex, invertCutSegments,
|
||||
}) => {
|
||||
if (!cutSegments && invertCutSegments) {
|
||||
return <div>Make sure you have no overlapping segments.</div>;
|
||||
}
|
||||
|
||||
if (!cutSegments || cutSegments.length === 0) {
|
||||
return <div>No segments to export.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<div style={{ fontSize: 14, marginBottom: 10 }}>Segments to export:</div>
|
||||
{cutSegments.map((seg, index) => {
|
||||
const duration = seg.end - seg.start;
|
||||
const durationMs = duration * 1000;
|
||||
|
||||
const isActive = !invertCutSegments && currentSegIndex === index;
|
||||
const uuid = seg.uuid || `${seg.start}`;
|
||||
|
||||
function renderNumber() {
|
||||
if (invertCutSegments) return <FaSave style={{ color: saveColor, marginRight: 5, verticalAlign: 'middle' }} size={14} />;
|
||||
|
||||
const {
|
||||
segBgColor, segBorderColor,
|
||||
} = getSegColors(seg);
|
||||
|
||||
return <b style={{ color: 'white', padding: '0 3px', marginRight: 5, background: segBgColor, border: `1px solid ${isActive ? segBorderColor : 'transparent'}`, borderRadius: 10, fontSize: 12 }}>{index + 1}</b>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
role="button"
|
||||
onClick={() => !invertCutSegments && onSegClick(index)}
|
||||
key={uuid}
|
||||
positionTransition
|
||||
style={{ originY: 0, margin: '5px 0', border: `1px solid rgba(255,255,255,${isActive ? 1 : 0.3})`, padding: 5, borderRadius: 5 }}
|
||||
initial={{ scaleY: 0 }}
|
||||
animate={{ scaleY: 1 }}
|
||||
exit={{ scaleY: 0 }}
|
||||
>
|
||||
<div style={{ fontSize: 13, whiteSpace: 'nowrap', color: 'white', marginBottom: 3 }}>
|
||||
{renderNumber()}
|
||||
<span>{formatTimecode(seg.start)} - {formatTimecode(seg.end)}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
Duration {prettyMs(durationMs)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12 }}>
|
||||
({Math.floor(durationMs)} ms, {getFrameCount(duration)} frames)
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'white' }}>{seg.name}</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
export default SegmentList;
|
@ -39,9 +39,11 @@ const Stream = memo(({ stream, onToggle, copyStream }) => {
|
||||
});
|
||||
}
|
||||
|
||||
const onClick = () => onToggle && onToggle(stream.index);
|
||||
|
||||
return (
|
||||
<tr style={{ opacity: copyStream ? undefined : 0.4 }}>
|
||||
<td><Icon size={20} style={{ padding: '0 5px', cursor: 'pointer' }} role="button" onClick={() => onToggle && onToggle(stream.index)} /></td>
|
||||
<td><Icon size={20} style={{ padding: '0 5px', cursor: 'pointer' }} role="button" onClick={onClick} /></td>
|
||||
<td>{stream.index}</td>
|
||||
<td>{stream.codec_type}</td>
|
||||
<td>{stream.codec_tag !== '0x0000' && stream.codec_tag_string}</td>
|
||||
@ -50,7 +52,7 @@ const Stream = memo(({ stream, onToggle, copyStream }) => {
|
||||
<td>{stream.nb_frames}</td>
|
||||
<td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</td>
|
||||
<td>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(1)}fps`}</td>
|
||||
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream)} size={30} /></td>
|
||||
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream)} size={26} /></td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
@ -78,7 +80,7 @@ const StreamsSelector = memo(({
|
||||
<table style={{ marginBottom: 10 }}>
|
||||
<thead style={{ background: 'rgba(0,0,0,0.1)' }}>
|
||||
<tr>
|
||||
<th>?</th>
|
||||
<th>Keep?</th>
|
||||
<th />
|
||||
<th>Type</th>
|
||||
<th>Tag</th>
|
||||
@ -104,8 +106,9 @@ const StreamsSelector = memo(({
|
||||
{Object.entries(externalFiles).map(([path, { streams }]) => (
|
||||
<Fragment key={path}>
|
||||
<tr>
|
||||
<td><FaTrashAlt size={20} role="button" style={{ padding: '0 5px', cursor: 'pointer' }} onClick={() => removeFile(path)} /></td>
|
||||
<td colSpan={9} style={{ paddingTop: 15 }}>
|
||||
{path} <FaTrashAlt role="button" onClick={() => removeFile(path)} />
|
||||
{path}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
|
3
src/colors.js
Normal file
3
src/colors.js
Normal file
@ -0,0 +1,3 @@
|
||||
export const saveColor = 'hsl(158, 100%, 43%)';
|
||||
export const primaryColor = 'hsl(194, 78%, 47%)';
|
||||
export const controlsBackground = '#6b6b6b';
|
@ -1,4 +1,4 @@
|
||||
import React, { memo, useEffect, useState, useCallback, useRef, Fragment } from 'react';
|
||||
import React, { memo, useEffect, useState, useCallback, useRef, Fragment, useMemo } from 'react';
|
||||
import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io';
|
||||
import { FaPlus, FaMinus, FaHandPointRight, FaHandPointLeft, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang, FaFileExport, FaTag } from 'react-icons/fa';
|
||||
import { MdRotate90DegreesCcw, MdCallSplit, MdCallMerge } from 'react-icons/md';
|
||||
@ -17,10 +17,12 @@ import flatMap from 'lodash/flatMap';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
|
||||
import HelpSheet from './HelpSheet';
|
||||
import SegmentList from './SegmentList';
|
||||
import TimelineSeg from './TimelineSeg';
|
||||
import InverseCutSegment from './InverseCutSegment';
|
||||
import StreamsSelector from './StreamsSelector';
|
||||
import { loadMifiLink } from './mifi';
|
||||
import { primaryColor, controlsBackground } from './colors';
|
||||
|
||||
import loadingLottie from './7077-magic-flow.json';
|
||||
|
||||
@ -351,6 +353,11 @@ const App = memo(() => {
|
||||
seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined,
|
||||
}), [detectedFps, timecodeShowFrames]);
|
||||
|
||||
const getFrameCount = useCallback((sec) => {
|
||||
if (detectedFps == null) return undefined;
|
||||
return Math.floor(sec * detectedFps);
|
||||
}, [detectedFps]);
|
||||
|
||||
const getCurrentTime = useCallback(() => (
|
||||
playing ? playerTime : commandedTime), [commandedTime, playerTime, playing]);
|
||||
|
||||
@ -683,6 +690,9 @@ const App = memo(() => {
|
||||
}
|
||||
}, [filePath, html5FriendlyPath, resetState, working]);
|
||||
|
||||
const outSegments = useMemo(() => (invertCutSegments ? inverseCutSegments : apparentCutSegments),
|
||||
[invertCutSegments, inverseCutSegments, apparentCutSegments]);
|
||||
|
||||
const cutClick = useCallback(async () => {
|
||||
if (working) {
|
||||
errorToast('I\'m busy');
|
||||
@ -699,19 +709,17 @@ const App = memo(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const segments = invertCutSegments ? inverseCutSegments : apparentCutSegments;
|
||||
|
||||
if (!segments) {
|
||||
if (!outSegments) {
|
||||
errorToast('No segments to export!');
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegSegments = segments.map((seg) => ({
|
||||
const ffmpegSegments = outSegments.map((seg) => ({
|
||||
cutFrom: seg.start,
|
||||
cutTo: seg.end,
|
||||
}));
|
||||
|
||||
if (segments.length < 1) {
|
||||
if (outSegments.length < 1) {
|
||||
errorToast('No segments to export');
|
||||
return;
|
||||
}
|
||||
@ -768,7 +776,7 @@ const App = memo(() => {
|
||||
setWorking(false);
|
||||
}
|
||||
}, [
|
||||
effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments,
|
||||
effectiveRotation, outSegments,
|
||||
working, duration, filePath, keyframeCut, detectedFileFormat,
|
||||
autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy,
|
||||
exportExtraStreams, nonCopiedExtraStreams, outputDir,
|
||||
@ -898,6 +906,8 @@ const App = memo(() => {
|
||||
|
||||
const toggleHelp = () => setHelpVisible(val => !val);
|
||||
|
||||
const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length]);
|
||||
|
||||
useEffect(() => {
|
||||
Mousetrap.bind('space', () => playCommand());
|
||||
Mousetrap.bind('k', () => playCommand());
|
||||
@ -905,6 +915,8 @@ const App = memo(() => {
|
||||
Mousetrap.bind('l', () => changePlaybackRate(1));
|
||||
Mousetrap.bind('left', () => seekRel(-1));
|
||||
Mousetrap.bind('right', () => seekRel(1));
|
||||
Mousetrap.bind('up', () => jumpSeg(-1));
|
||||
Mousetrap.bind('down', () => jumpSeg(1));
|
||||
Mousetrap.bind('.', () => shortStep(1));
|
||||
Mousetrap.bind(',', () => shortStep(-1));
|
||||
Mousetrap.bind('c', () => capture());
|
||||
@ -923,6 +935,8 @@ const App = memo(() => {
|
||||
Mousetrap.unbind('l');
|
||||
Mousetrap.unbind('left');
|
||||
Mousetrap.unbind('right');
|
||||
Mousetrap.unbind('up');
|
||||
Mousetrap.unbind('down');
|
||||
Mousetrap.unbind('.');
|
||||
Mousetrap.unbind(',');
|
||||
Mousetrap.unbind('c');
|
||||
@ -936,7 +950,7 @@ const App = memo(() => {
|
||||
};
|
||||
}, [
|
||||
addCutSegment, capture, changePlaybackRate, cutClick, playCommand, removeCutSegment,
|
||||
setCutEnd, setCutStart, seekRel, shortStep, deleteSource,
|
||||
setCutEnd, setCutStart, seekRel, shortStep, deleteSource, jumpSeg,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -1487,13 +1501,13 @@ const App = memo(() => {
|
||||
);
|
||||
}
|
||||
|
||||
const primaryColor = 'hsl(194, 78%, 47%)';
|
||||
const rightBarWidth = 200; // TODO responsive
|
||||
|
||||
const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ background: '#6b6b6b', height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between' }}>
|
||||
<div style={{ background: controlsBackground, height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between' }}>
|
||||
{filePath && (
|
||||
<Fragment>
|
||||
<SideSheet
|
||||
@ -1612,7 +1626,7 @@ const App = memo(() => {
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div style={{ position: 'absolute', top: topBarHeight, left: 0, right: 0, bottom: bottomBarHeight }}>
|
||||
<div style={{ position: 'absolute', top: topBarHeight, left: 0, right: rightBarWidth, bottom: bottomBarHeight }}>
|
||||
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
|
||||
<video
|
||||
muted={muted}
|
||||
@ -1638,7 +1652,7 @@ const App = memo(() => {
|
||||
|
||||
{rotationPreviewRequested && (
|
||||
<div style={{
|
||||
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: 0, color: 'white',
|
||||
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: rightBarWidth, color: 'white',
|
||||
}}
|
||||
>
|
||||
Lossless rotation preview
|
||||
@ -1646,20 +1660,38 @@ const App = memo(() => {
|
||||
)}
|
||||
|
||||
{filePath && (
|
||||
<div style={{
|
||||
position: 'absolute', margin: '1em', right: 0, bottom: bottomBarHeight, color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<VolumeIcon
|
||||
title="Mute preview? (will not affect output)"
|
||||
size={30}
|
||||
role="button"
|
||||
onClick={toggleMute}
|
||||
/>
|
||||
</div>
|
||||
<Fragment>
|
||||
<div style={{
|
||||
position: 'absolute', margin: '1em', right: rightBarWidth, bottom: bottomBarHeight, color: 'rgba(255,255,255,0.7)',
|
||||
}}
|
||||
>
|
||||
<VolumeIcon
|
||||
title="Mute preview? (will not affect output)"
|
||||
size={30}
|
||||
role="button"
|
||||
onClick={toggleMute}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
position: 'absolute', width: rightBarWidth, padding: '0 10px', right: 0, boxSizing: 'border-box', bottom: bottomBarHeight, top: topBarHeight, background: controlsBackground, color: 'rgba(255,255,255,0.7)', overflowY: 'scroll',
|
||||
}}
|
||||
>
|
||||
<SegmentList
|
||||
currentSegIndex={currentSegIndexSafe}
|
||||
onSegClick={setCurrentSegIndex}
|
||||
formatTimecode={formatTimecode}
|
||||
cutSegments={outSegments}
|
||||
getFrameCount={getFrameCount}
|
||||
getSegColors={getSegColors}
|
||||
invertCutSegments={invertCutSegments}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
<div className="controls-wrapper" style={{ height: bottomBarHeight }}>
|
||||
<div className="controls-wrapper" style={{ height: bottomBarHeight, background: controlsBackground }}>
|
||||
<Hammer
|
||||
onTap={handleTap}
|
||||
onPan={handleTap}
|
||||
@ -1692,7 +1724,7 @@ const App = memo(() => {
|
||||
segBgColor={segBgColor}
|
||||
segActiveBgColor={segActiveBgColor}
|
||||
segBorderColor={segBorderColor}
|
||||
onSegClick={currentSegIndexNew => setCurrentSegIndex(currentSegIndexNew)}
|
||||
onSegClick={setCurrentSegIndex}
|
||||
isActive={i === currentSegIndexSafe}
|
||||
duration={durationSafe}
|
||||
name={seg.name}
|
||||
@ -1888,8 +1920,6 @@ const App = memo(() => {
|
||||
onTogglePress={toggleHelp}
|
||||
renderSettings={renderSettings}
|
||||
ffmpegCommandLog={ffmpegCommandLog}
|
||||
sortedCutSegments={sortedCutSegments}
|
||||
formatTimecode={formatTimecode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
12
yarn.lock
12
yarn.lock
@ -4553,6 +4553,11 @@ parse-json@^5.0.0:
|
||||
json-parse-better-errors "^1.0.1"
|
||||
lines-and-columns "^1.1.6"
|
||||
|
||||
parse-ms@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/parse-ms/-/parse-ms-2.1.0.tgz#348565a753d4391fa524029956b172cb7753097d"
|
||||
integrity sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==
|
||||
|
||||
path-exists@^2.0.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-2.1.0.tgz#0feb6c64f0fc518d9a754dd5efb62c7022761f4b"
|
||||
@ -4716,6 +4721,13 @@ preserve@^0.2.0:
|
||||
resolved "https://registry.yarnpkg.com/preserve/-/preserve-0.2.0.tgz#815ed1f6ebc65926f865b310c0713bcb3315ce4b"
|
||||
integrity sha1-gV7R9uvGWSb4ZbMQwHE7yzMVzks=
|
||||
|
||||
pretty-ms@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/pretty-ms/-/pretty-ms-6.0.0.tgz#39a0eb5f31d359bcee43c9579e6ddf4a02a82ff0"
|
||||
integrity sha512-X5i1y9/8VuBMb9WU8zubTiLKnJG4lcKvL7eaCEVc/jpTe3aS74gCcBM6Yd1vvUDoTCXm4Y15obNS/16yB0FTaQ==
|
||||
dependencies:
|
||||
parse-ms "^2.1.0"
|
||||
|
||||
private@^0.1.6, private@^0.1.7:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/private/-/private-0.1.8.tgz#2381edb3689f7a53d653190060fcf822d2f368ff"
|
||||
|
Loading…
Reference in New Issue
Block a user