1
0
mirror of https://github.com/mifi/lossless-cut.git synced 2024-11-22 10:22:31 +01:00

UI improvements #128 #189

This commit is contained in:
Mikael Finstad 2020-02-16 20:38:45 +08:00
parent bb601d2545
commit bab424131f
4 changed files with 204 additions and 120 deletions

View File

@ -5,12 +5,11 @@ const { formatDuration } = require('./util');
const TimelineSeg = ({
isCutRangeValid, duration: durationRaw, cutStartTime, cutEndTime, apparentCutStart,
apparentCutEnd, isActive, segNum, onSegClick, color,
isCutRangeValid, duration, apparentCutStart, apparentCutEnd, isActive, segNum,
onSegClick, color,
}) => {
const duration = durationRaw || 1;
const cutSectionWidth = `${((apparentCutEnd - apparentCutStart) / duration) * 100}%`;
const markerWidth = 4;
const cutSectionWidth = `${Math.max(((apparentCutEnd - apparentCutStart) / duration) * 100, 1)}%`;
const startTimePos = `${(apparentCutStart / duration) * 100}%`;
const markerBorder = isActive ? `2px solid ${color.lighten(0.5).string()}` : undefined;
@ -30,29 +29,39 @@ const TimelineSeg = ({
borderBottomRightRadius: markerBorderRadius,
};
const wrapperStyle = {
position: 'absolute',
top: 0,
bottom: 0,
left: startTimePos,
width: cutSectionWidth,
display: 'flex',
background: backgroundColor,
originX: 0,
borderRadius: markerBorderRadius,
};
const onThisSegClick = () => onSegClick(segNum);
return (
<motion.div
style={{ position: 'absolute', top: 0, bottom: 0, left: startTimePos, width: cutSectionWidth, display: 'flex', background: backgroundColor, originX: 0 }}
style={wrapperStyle}
layoutTransition={{ type: 'spring', damping: 30, stiffness: 1000 }}
initial={{ opacity: 0, scaleX: 0 }}
animate={{ opacity: 1, scaleX: 1 }}
exit={{ opacity: 0, scaleX: 0 }}
role="button"
onClick={onThisSegClick}
>
{cutStartTime !== undefined && (
<div style={startMarkerStyle} role="button" tabIndex="0" />
)}
{isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && (
<div
role="button"
tabIndex="0"
style={{ flexGrow: 1 }}
title={`${formatDuration({ seconds: cutEndTime - cutStartTime })}`}
/>
)}
{cutEndTime !== undefined && (
<div style={startMarkerStyle} role="button" tabIndex="0" />
<div
style={{ flexGrow: 1, textAlign: 'left', fontSize: 10 }}
title={apparentCutEnd > apparentCutStart && formatDuration({ seconds: apparentCutEnd - apparentCutStart })}
>
{segNum + 1}
</div>
{apparentCutEnd > apparentCutStart && (
<div style={endMarkerStyle} role="button" tabIndex="0" />
)}
</motion.div>

View File

@ -63,16 +63,16 @@ function handleProgress(process, cutDuration, onProgress) {
}
async function cut({
filePath, format, cutFrom, cutTo, cutToApparent, videoDuration, rotation,
filePath, format, cutFrom, cutTo, videoDuration, rotation,
onProgress, copyStreamIds, keyframeCut, outPath,
}) {
console.log('Cutting from', cutFrom, 'to', cutToApparent);
console.log('Cutting from', cutFrom, 'to', cutTo);
const cutDuration = cutToApparent - cutFrom;
const cutDuration = cutTo - cutFrom;
// https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = cutFrom === 0 ? [] : ['-ss', cutFrom];
const cutToArgs = cutTo === undefined || cutTo === videoDuration ? [] : ['-t', cutDuration];
const cutToArgs = cutTo === videoDuration ? [] : ['-t', cutDuration];
const inputCutArgs = keyframeCut ? [
...cutFromArgs,
@ -133,9 +133,9 @@ async function cutMultiple({
let i = 0;
// eslint-disable-next-line no-restricted-syntax,no-unused-vars
for (const { cutFrom, cutTo, cutToApparent } of segments) {
for (const { cutFrom, cutTo } of segments) {
const ext = path.extname(filePath) || `.${format}`;
const cutSpecification = `${formatDuration({ seconds: cutFrom, fileNameFriendly: true })}-${formatDuration({ seconds: cutToApparent, fileNameFriendly: true })}`;
const cutSpecification = `${formatDuration({ seconds: cutFrom, fileNameFriendly: true })}-${formatDuration({ seconds: cutTo, fileNameFriendly: true })}`;
const outPath = getOutPath(customOutDir, filePath, `${cutSpecification}${ext}`);
@ -151,7 +151,6 @@ async function cutMultiple({
keyframeCut,
cutFrom,
cutTo,
cutToApparent,
// eslint-disable-next-line no-loop-func
onProgress: progress => onSingleProgress(i, progress),
});

View File

@ -61,12 +61,6 @@ input, button, textarea, :focus {
text-align: center;
}
#current-time-display {
text-align: center;
color: rgba(255, 255, 255, 0.8);
padding: .5em;
}
.timeline-wrapper {
width: 100%;
position: relative;

View File

@ -9,6 +9,7 @@ import { Popover, Button } from 'evergreen-ui';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
import clone from 'lodash/clone';
import sortBy from 'lodash/sortBy';
import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg';
@ -113,6 +114,13 @@ const App = memo(() => {
setCopyStreamIds(v => ({ ...v, [index]: !v[index] }));
}
function toggleMute() {
setMuted((v) => {
if (!v) toast.fire({ title: 'Muted preview (note that exported file will not be affected)' });
return !v;
});
}
function seekAbs(val) {
const video = videoRef.current;
if (val == null || Number.isNaN(val)) return;
@ -167,6 +175,71 @@ const App = memo(() => {
const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath);
// Because segments could have undefined start / end (meaning extend to start of timeline or end duration)
function getSegApparentStart(time) {
if (time !== undefined) return time;
return 0;
}
const getSegApparentEnd = useCallback((time) => {
if (time !== undefined) return time;
if (duration !== undefined) return duration;
return 0; // Haven't gotten duration yet
}, [duration]);
const apparentCutSegments = cutSegments.map(cutSegment => ({
...cutSegment,
start: getSegApparentStart(cutSegment.start),
end: getSegApparentEnd(cutSegment.end),
}));
const invalidSegUuids = apparentCutSegments
.filter(cutSegment => cutSegment.start >= cutSegment.end)
.map(cutSegment => cutSegment.uuid);
const haveInvalidSegs = invalidSegUuids.length > 0;
const inverseCutSegments = (() => {
if (haveInvalidSegs) return undefined;
if (apparentCutSegments.length < 1) return undefined;
const sorted = sortBy(apparentCutSegments, 'start');
const foundOverlap = sorted.some((cutSegment, i) => {
if (i === 0) return false;
return sorted[i - 1].end > cutSegment.start;
});
if (foundOverlap) return undefined;
const ret = [];
if (sorted[0].start > 0) {
ret.push({
start: 0,
end: sorted[0].start,
});
}
sorted.forEach((cutSegment, i) => {
if (i === 0) return;
ret.push({
start: sorted[i - 1].end,
end: cutSegment.start,
});
});
const last = sorted[sorted.length - 1];
if (last.end < duration) {
ret.push({
start: last.end,
end: duration,
});
}
return ret;
})();
const setCutTime = useCallback((type, time) => {
const cloned = clone(cutSegments);
cloned[currentSeg][type] = time;
@ -180,12 +253,9 @@ const App = memo(() => {
const getCutSeg = useCallback((i) => cutSegments[i !== undefined ? i : currentSeg],
[currentSeg, cutSegments]);
const getCutStartTime = useCallback((i) => getCutSeg(i).start, [getCutSeg]);
const getCutEndTime = useCallback((i) => getCutSeg(i).end, [getCutSeg]);
const addCutSegment = useCallback(() => {
const cutStartTime = getCutStartTime();
const cutEndTime = getCutEndTime();
const cutStartTime = getCutSeg().start;
const cutEndTime = getCutSeg().end;
if (cutStartTime === undefined && cutEndTime === undefined) return;
@ -204,7 +274,7 @@ const App = memo(() => {
setCutSegments(cutSegmentsNew);
setCurrentSeg(currentSegNew);
}, [
getCutEndTime, getCutStartTime, cutSegments, currentTime, duration,
getCutSeg, cutSegments, currentTime, duration,
]);
const setCutStart = useCallback(() => {
@ -286,18 +356,12 @@ const App = memo(() => {
setRotationPreviewRequested(true);
}
const getApparentCutStartTime = useCallback((i) => {
const cutStartTime = getCutStartTime(i);
if (cutStartTime !== undefined) return cutStartTime;
return 0;
}, [getCutStartTime]);
const getApparentCutStartTimeOrCurrent = useCallback((i) => getSegApparentStart(getCutSeg(i).start), [getCutSeg]);
const getApparentCutEndTime = useCallback((i) => {
const cutEndTime = getCutEndTime(i);
if (cutEndTime !== undefined) return cutEndTime;
if (duration !== undefined) return duration;
return 0; // Haven't gotten duration yet
}, [getCutEndTime, duration]);
const getApparentCutEndTimeOrCurrent = useCallback((i) => {
const cutEndTime = getCutSeg(i).end;
return getSegApparentEnd(cutEndTime);
}, [getCutSeg, getSegApparentEnd]);
const offsetCurrentTime = (currentTime || 0) + startTimeOffset;
@ -343,8 +407,8 @@ const App = memo(() => {
setCutSegments(cutSegmentsNew);
}, [currentSeg, cutSegments]);
const jumpCutStart = () => seekAbs(getApparentCutStartTime());
const jumpCutEnd = () => seekAbs(getApparentCutEndTime());
const jumpCutStart = () => seekAbs(getApparentCutStartTimeOrCurrent());
const jumpCutEnd = () => seekAbs(getApparentCutEndTimeOrCurrent());
function handleTap(e) {
const target = timelineWrapperRef.current;
@ -366,8 +430,10 @@ const App = memo(() => {
}, [playing]);
async function deleteSourceClick() {
if (!filePath) return;
// eslint-disable-next-line no-alert
if (working || !window.confirm('Are you sure you want to move the source file to trash?')) return;
if (working || !window.confirm(`Are you sure you want to move the source file to trash? ${filePath}`)) return;
try {
setWorking(true);
@ -381,19 +447,13 @@ const App = memo(() => {
}
}
const isCutRangeValid = useCallback((i) => getApparentCutStartTime(i) < getApparentCutEndTime(i),
[getApparentCutStartTime, getApparentCutEndTime]);
const cutClick = useCallback(async () => {
if (working) {
errorToast('I\'m busy');
return;
}
const cutStartTime = getCutStartTime();
const cutEndTime = getCutEndTime();
if (!(isCutRangeValid() || cutEndTime === undefined || cutStartTime === undefined)) {
if (haveInvalidSegs) {
errorToast('Start time must be before end time');
return;
}
@ -402,9 +462,8 @@ const App = memo(() => {
setWorking(true);
const segments = cutSegments.map((seg, i) => ({
cutFrom: getApparentCutStartTime(i),
cutTo: getCutEndTime(i),
cutToApparent: getApparentCutEndTime(i),
cutFrom: getApparentCutStartTimeOrCurrent(i),
cutTo: getApparentCutEndTimeOrCurrent(i),
}));
const outFiles = await ffmpeg.cutMultiple({
@ -444,9 +503,9 @@ const App = memo(() => {
setWorking(false);
}
}, [
effectiveRotation, getApparentCutStartTime, getApparentCutEndTime, getCutEndTime,
getCutStartTime, isCutRangeValid, working, cutSegments, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, copyStreamIds,
effectiveRotation, getApparentCutStartTimeOrCurrent, getApparentCutEndTimeOrCurrent,
working, cutSegments, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, copyStreamIds, haveInvalidSegs,
]);
function showUnsupportedFileMessage() {
@ -499,6 +558,7 @@ const App = memo(() => {
return ret;
}, [getHtml5ifiedPath]);
const load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested });
if (working) {
@ -737,7 +797,7 @@ const App = memo(() => {
seekAbs(rel);
};
const cutTime = type === 'start' ? getApparentCutStartTime() : getApparentCutEndTime();
const cutTime = type === 'start' ? getApparentCutStartTimeOrCurrent() : getApparentCutEndTimeOrCurrent();
return (
<input
@ -756,7 +816,7 @@ const App = memo(() => {
const durationSafe = duration || 1;
const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`;
const segColor = getCutSeg().color;
const segColor = (getCutSeg() || {}).color;
const segBgColor = segColor.alpha(0.5).string();
const jumpCutButtonStyle = {
@ -797,17 +857,20 @@ const App = memo(() => {
<td>Output format (default autodetected)</td>
<td style={{ width: '50%' }}>{renderOutFmt()}</td>
</tr>
<tr>
<td>In timecode show</td>
<td>Output directory</td>
<td>
<button
type="button"
onClick={() => setTimecodeShowFrames(v => !v)}
onClick={setOutputDir}
>
{timecodeShowFrames ? 'frame numbers' : 'millisecond fractions'}
{customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
</button>
<div>{customOutDir}</div>
</td>
</tr>
<tr>
<td>Auto merge segments to one file after export?</td>
<td>
@ -846,19 +909,6 @@ const App = memo(() => {
</td>
</tr>
<tr>
<td>Output directory</td>
<td>
<button
type="button"
onClick={setOutputDir}
>
{customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
</button>
<div>{customOutDir}</div>
</td>
</tr>
<tr>
<td>
Snapshot capture format
@ -867,6 +917,18 @@ const App = memo(() => {
{renderCaptureFormatButton()}
</td>
</tr>
<tr>
<td>In timecode show</td>
<td>
<button
type="button"
onClick={() => setTimecodeShowFrames(v => !v)}
>
{timecodeShowFrames ? 'Frame numbers' : 'Millisecond fractions'}
</button>
</td>
</tr>
</tbody>
</table>
);
@ -950,21 +1012,21 @@ const App = memo(() => {
)}
{working && (
<div style={{
color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: topBarHeight, left: 0,
}}
>
<i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} />
{cutProgress != null && (
<span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}>
{`${Math.floor(cutProgress * 100)} %`}
</span>
)}
</div>
<div style={{
color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: topBarHeight, left: 0,
}}
>
<i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} />
{cutProgress != null && (
<span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}>
{`${Math.floor(cutProgress * 100)} %`}
</span>
)}
</div>
)}
{/* eslint-disable jsx-a11y/media-has-caption */}
<div style={{ position: 'absolute', top: topBarHeight, left: 0, right: 0, bottom: bottomBarHeight }}>
{/* eslint-disable-next-line jsx-a11y/media-has-caption */}
<video
muted={muted}
ref={videoRef}
@ -986,7 +1048,6 @@ const App = memo(() => {
/>
)}
</div>
{/* eslint-enable jsx-a11y/media-has-caption */}
{rotationPreviewRequested && (
<div style={{
@ -1006,7 +1067,7 @@ const App = memo(() => {
title="Mute preview? (will not affect output)"
size={30}
role="button"
onClick={() => setMuted(v => !v)}
onClick={toggleMute}
/>
</div>
)}
@ -1029,18 +1090,39 @@ const App = memo(() => {
color={seg.color}
onSegClick={currentSegNew => setCurrentSeg(currentSegNew)}
isActive={i === currentSeg}
isCutRangeValid={isCutRangeValid(i)}
duration={durationSafe}
cutStartTime={getCutStartTime(i)}
cutEndTime={getCutEndTime(i)}
apparentCutStart={getApparentCutStartTime(i)}
apparentCutEnd={getApparentCutEndTime(i)}
apparentCutStart={getApparentCutStartTimeOrCurrent(i)}
apparentCutEnd={getApparentCutEndTimeOrCurrent(i)}
/>
))}
</AnimatePresence>
<div id="current-time-display">
<span role="button" onClick={() => setTimecodeShowFrames(v => !v)}>
<div>
{inverseCutSegments && inverseCutSegments.map((seg) => (
<div
key={seg.start}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `${(seg.start / duration) * 100}%`,
width: `${Math.max(((seg.end - seg.start) / duration) * 100, 1)}%`,
display: 'flex',
alignItems: 'center',
}}
>
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
<FaTrashAlt
style={{ color: 'rgba(255, 255, 255, 0.3)' }}
size={16}
/>
<div style={{ flexGrow: 1, borderBottom: '1px dashed rgba(255, 255, 255, 0.3)', marginLeft: 5, marginRight: 5 }} />
</div>
))}
</div>
<div style={{ textAlign: 'center', color: 'rgba(255, 255, 255, 0.8)', padding: '.5em' }}>
<span role="button" onClick={() => setTimecodeShowFrames(v => !v)} style={{ background: 'rgba(0,0,0,0.5)', borderRadius: 3, padding: '2px 4px' }}>
{formatTimecode(offsetCurrentTime)}
</span>
</div>
@ -1049,6 +1131,14 @@ const App = memo(() => {
</Hammer>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16}
onClick={setCutStart}
role="button"
/>
<i
className="button fa fa-step-backward"
role="button"
@ -1107,26 +1197,10 @@ const App = memo(() => {
title="Jump to end of video"
onClick={() => seekAbs(durationSafe)}
/>
</div>
<div>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }}
size={16}
onClick={setCutStart}
role="button"
/>
<FaTrashAlt
title="Delete source file"
style={{ padding: 5 }}
size={16}
onClick={deleteSourceClick}
role="button"
/>
<FaAngleRight
title="Set cut end to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }}
style={{ background: segBgColor, borderRadius: 10, padding: 3 }}
size={16}
onClick={setCutEnd}
role="button"
@ -1139,7 +1213,7 @@ const App = memo(() => {
size={30}
style={{ margin: '0 5px', color: 'white' }}
role="button"
title="Add cut segment"
title="Add segment"
onClick={addCutSegment}
/>
@ -1164,6 +1238,14 @@ const App = memo(() => {
/>
</div>
<FaTrashAlt
title="Delete source file"
style={{ padding: '5px 10px' }}
size={16}
onClick={deleteSourceClick}
role="button"
/>
{renderCaptureFormatButton()}
<IoIosCamera