diff --git a/src/TimelineSeg.jsx b/src/TimelineSeg.jsx index 6fd764e9..7ca78371 100644 --- a/src/TimelineSeg.jsx +++ b/src/TimelineSeg.jsx @@ -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 ( - {cutStartTime !== undefined && ( -
- )} - {isCutRangeValid && (cutStartTime !== undefined || cutEndTime !== undefined) && ( -
- )} - {cutEndTime !== undefined && ( +
+ +
apparentCutStart && formatDuration({ seconds: apparentCutEnd - apparentCutStart })} + > + {segNum + 1} +
+ {apparentCutEnd > apparentCutStart && (
)} diff --git a/src/ffmpeg.js b/src/ffmpeg.js index 39d28eee..599cd3bd 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -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), }); diff --git a/src/main.css b/src/main.css index af026a53..ae1ca39c 100644 --- a/src/main.css +++ b/src/main.css @@ -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; diff --git a/src/renderer.jsx b/src/renderer.jsx index 06511f85..bcf7fad5 100644 --- a/src/renderer.jsx +++ b/src/renderer.jsx @@ -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 ( { 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(() => { Output format (default autodetected) {renderOutFmt()} + - In timecode show + Output directory +
{customOutDir}
+ Auto merge segments to one file after export? @@ -847,24 +910,23 @@ const App = memo(() => { - Output directory - -
{customOutDir}
+ Snapshot capture format + + + {renderCaptureFormatButton()} + In timecode show - Snapshot capture format - - - {renderCaptureFormatButton()} + @@ -950,21 +1012,21 @@ const App = memo(() => { )} {working && ( -
- - {cutProgress != null && ( - - {`${Math.floor(cutProgress * 100)} %`} - - )} -
+
+ + {cutProgress != null && ( + + {`${Math.floor(cutProgress * 100)} %`} + + )} +
)} - {/* eslint-disable jsx-a11y/media-has-caption */}
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
- {/* eslint-enable jsx-a11y/media-has-caption */} {rotationPreviewRequested && (
{ title="Mute preview? (will not affect output)" size={30} role="button" - onClick={() => setMuted(v => !v)} + onClick={toggleMute} />
)} @@ -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)} /> ))} -
- setTimecodeShowFrames(v => !v)}> +
+ {inverseCutSegments && inverseCutSegments.map((seg) => ( +
+
+ +
+
+ ))} +
+ +
+ setTimecodeShowFrames(v => !v)} style={{ background: 'rgba(0,0,0,0.5)', borderRadius: 3, padding: '2px 4px' }}> {formatTimecode(offsetCurrentTime)}
@@ -1049,6 +1131,14 @@ const App = memo(() => {
+ + { title="Jump to end of video" onClick={() => seekAbs(durationSafe)} /> -
-
- - { 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(() => { />
+ + {renderCaptureFormatButton()}