improvements:

- show keyframes on timeline
- add help to help menu
- improve buttons
- round all seeking to frame time
- bugfixes
pull/276/head
Mikael Finstad 6 years ago
parent 2f45f28a3e
commit d91e9b1c45

@ -57,9 +57,9 @@ const HelpSheet = memo(({
<h1 style={{ marginTop: 40 }}>Last ffmpeg commands</h1> <h1 style={{ marginTop: 40 }}>Last ffmpeg commands</h1>
<div style={{ overflowY: 'scroll', height: 200 }}> <div style={{ overflowY: 'scroll', height: 200 }}>
{ffmpegCommandLog.reverse().map((log) => ( {ffmpegCommandLog.reverse().map(({ command, time }) => (
<div key={log} style={{ whiteSpace: 'pre', margin: '5px 0' }}> <div key={time} style={{ whiteSpace: 'pre', margin: '5px 0' }}>
<FaClipboard style={{ cursor: 'pointer' }} title="Copy to clipboard" onClick={() => { clipboard.writeText(log); toast.fire({ timer: 2000, icon: 'success', title: 'Copied to clipboard' }); }} /> {log} <FaClipboard style={{ cursor: 'pointer' }} title="Copy to clipboard" onClick={() => { clipboard.writeText(command); toast.fire({ timer: 2000, icon: 'success', title: 'Copied to clipboard' }); }} /> {command}
</div> </div>
))} ))}
</div> </div>

@ -1,6 +1,6 @@
import React, { memo, Fragment } from 'react'; import React, { memo, Fragment } from 'react';
import prettyMs from 'pretty-ms'; import prettyMs from 'pretty-ms';
import { FaSave, FaPlus, FaMinus, FaTag } from 'react-icons/fa'; import { FaSave, FaPlus, FaMinus, FaTag, FaSortNumericDown } from 'react-icons/fa';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
@ -20,10 +20,7 @@ const SegmentList = memo(({
return <div style={{ padding: '0 10px' }}>No segments to export.</div>; return <div style={{ padding: '0 10px' }}>No segments to export.</div>;
} }
const { const { segActiveBgColor: currentSegActiveBgColor } = getSegColors(currentCutSeg);
segActiveBgColor: currentSegActiveBgColor,
segBorderColor: currentSegBorderColor,
} = getSegColors(currentCutSeg);
async function onLabelSegmentPress() { async function onLabelSegmentPress() {
const { value } = await Swal.fire({ const { value } = await Swal.fire({
@ -110,7 +107,7 @@ const SegmentList = memo(({
<div style={{ display: 'flex', padding: '5px 0', alignItems: 'center', justifyContent: 'center', borderBottom: '1px solid grey' }}> <div style={{ display: 'flex', padding: '5px 0', alignItems: 'center', justifyContent: 'center', borderBottom: '1px solid grey' }}>
<FaPlus <FaPlus
size={30} size={30}
style={{ margin: '0 5px', color: 'white', cursor: 'pointer' }} style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: 'rgba(255, 255, 255, 0.2)' }}
role="button" role="button"
title="Add segment" title="Add segment"
onClick={addCutSegment} onClick={addCutSegment}
@ -118,20 +115,19 @@ const SegmentList = memo(({
<FaMinus <FaMinus
size={30} size={30}
style={{ margin: '0 5px', background: cutSegments.length < 2 ? undefined : currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }} style={{ margin: '0 5px', borderRadius: 3, color: 'white', cursor: 'pointer', background: cutSegments.length < 2 ? 'rgba(255, 255, 255, 0.2)' : currentSegActiveBgColor }}
role="button" role="button"
title={`Delete current segment ${currentSegIndex + 1}`} title={`Delete current segment ${currentSegIndex + 1}`}
onClick={removeCutSegment} onClick={removeCutSegment}
/> />
<div <FaSortNumericDown
style={{ background: currentSegActiveBgColor, border: `2px solid ${currentSegBorderColor}`, borderRadius: 5, color: 'white', fontSize: 23, textAlign: 'center', fontWeight: 'bold', boxSizing: 'border-box', height: 30, width: 30, margin: '0 5px', cursor: 'pointer' }} size={20}
role="button"
title="Change segment order" title="Change segment order"
role="button"
style={{ padding: 4, margin: '0 5px', background: currentSegActiveBgColor, borderRadius: 3, color: 'white', cursor: 'pointer' }}
onClick={onReorderSegsPress} onClick={onReorderSegsPress}
> />
{currentSegIndex + 1}
</div>
<FaTag <FaTag
size={20} size={20}

@ -51,7 +51,7 @@ const Stream = memo(({ stream, onToggle, copyStream }) => {
<td>{!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}</td> <td>{!Number.isNaN(duration) && `${formatDuration({ seconds: duration })}`}</td>
<td>{stream.nb_frames}</td> <td>{stream.nb_frames}</td>
<td>{!Number.isNaN(bitrate) && `${(bitrate / 1e6).toFixed(1)}MBit/s`}</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>{stream.width && stream.height && `${stream.width}x${stream.height}`} {stream.channels && `${stream.channels}c`} {stream.channel_layout} {streamFps && `${streamFps.toFixed(2)}fps`}</td>
<td><FaInfoCircle role="button" onClick={() => onInfoClick(stream)} size={26} /></td> <td><FaInfoCircle role="button" onClick={() => onInfoClick(stream)} size={26} /></td>
</tr> </tr>
); );

@ -16,6 +16,12 @@ const os = require('os');
const { formatDuration, getOutPath, transferTimestamps } = require('./util'); const { formatDuration, getOutPath, transferTimestamps } = require('./util');
function getFfCommandLine(cmd, args) {
const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg);
return `${cmd} ${args.map(mapArg).join(' ')}`;
}
function getPath(type) { function getPath(type) {
const platform = os.platform(); const platform = os.platform();
@ -36,11 +42,13 @@ function getPath(type) {
async function runFfprobe(args) { async function runFfprobe(args) {
const ffprobePath = await getPath('ffprobe'); const ffprobePath = await getPath('ffprobe');
console.log(getFfCommandLine('ffprobe', args));
return execa(ffprobePath, args); return execa(ffprobePath, args);
} }
async function runFfmpeg(args) { async function runFfmpeg(args) {
const ffmpegPath = await getPath('ffmpeg'); const ffmpegPath = await getPath('ffmpeg');
console.log(getFfCommandLine('ffmpeg', args));
return execa(ffmpegPath, args); return execa(ffmpegPath, args);
} }
@ -80,22 +88,79 @@ function getExtensionForFormat(format) {
return ext || format; return ext || format;
} }
async function readFrames({ filePath, aroundTime, window = 30, stream }) {
const intervalsArgs = aroundTime != null ? ['-read_intervals', `${Math.max(aroundTime - window, 0)}%${aroundTime + window}`] : [];
const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', stream, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]);
return sortBy(JSON.parse(stdout).packets.map(p => ({ keyframe: p.flags[0] === 'K', time: parseFloat(p.pts_time, 10) })), 'time');
}
// https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame
// http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss
function getNextPrevKeyframe(frames, cutTime, nextMode) {
const sigma = 0.01;
const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma;
let index;
if (frames.length < 2) throw new Error('Less than 2 frames found');
if (nextMode) {
index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma);
if (index === -1) throw new Error('Failed to find next keyframe');
if (index >= frames.length - 1) throw new Error('We are on the last frame');
const { time } = frames[index];
if (isCloseTo(time, cutTime)) {
return undefined; // Already on keyframe, no need to modify cut time
}
return time;
}
const findReverseIndex = (arr, cb) => {
const ret = [...arr].reverse().findIndex(cb);
if (ret === -1) return -1;
return arr.length - 1 - ret;
};
index = findReverseIndex(frames, f => f.time <= cutTime + sigma);
if (index === -1) throw new Error('Failed to find any prev frame');
if (index === 0) throw new Error('We are on the first frame');
if (index === frames.length - 1) {
// Last frame of video, no need to modify cut time
return undefined;
}
if (frames[index + 1].keyframe) {
// Already on frame before keyframe, no need to modify cut time
return undefined;
}
// We are not on a frame before keyframe, look for preceding keyframe instead
index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma);
if (index === -1) throw new Error('Failed to find any prev keyframe');
if (index === 0) throw new Error('We are on the first keyframe');
// Use frame before the found keyframe
return frames[index - 1].time;
}
async function cut({ async function cut({
filePath, outFormat, cutFrom, cutTo, videoDuration, rotation, filePath, outFormat, cutFrom, cutTo, videoDuration, rotation,
onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog, onProgress, copyStreamIds, keyframeCut, outPath, appendFfmpegCommandLog,
}) { }) {
console.log('Cutting from', cutFrom, 'to', cutTo); console.log('Cutting from', cutFrom, 'to', cutTo);
const ssBeforeInput = keyframeCut;
const cutDuration = cutTo - cutFrom; const cutDuration = cutTo - cutFrom;
// https://github.com/mifi/lossless-cut/issues/50 // Don't cut if no need: https://github.com/mifi/lossless-cut/issues/50
const cutFromArgs = isCuttingStart(cutFrom) ? ['-ss', cutFrom] : []; const cutFromArgs = isCuttingStart(cutFrom) ? ['-ss', cutFrom.toFixed(5)] : [];
const cutToArgs = isCuttingEnd(cutTo, videoDuration) ? ['-t', cutDuration] : []; const cutToArgs = isCuttingEnd(cutTo, videoDuration) ? ['-t', cutDuration.toFixed(5)] : [];
const copyStreamIdsFiltered = copyStreamIds.filter(({ streamIds }) => streamIds.length > 0); const copyStreamIdsFiltered = copyStreamIds.filter(({ streamIds }) => streamIds.length > 0);
const inputArgs = flatMap(copyStreamIdsFiltered, ({ path }) => ['-i', path]); const inputArgs = flatMap(copyStreamIdsFiltered, ({ path }) => ['-i', path]);
const inputCutArgs = keyframeCut ? [ const inputCutArgs = ssBeforeInput ? [
...cutFromArgs, ...cutFromArgs,
...inputArgs, ...inputArgs,
...cutToArgs, ...cutToArgs,
@ -126,11 +191,10 @@ async function cut({
'-f', outFormat, '-y', outPath, '-f', outFormat, '-y', outPath,
]; ];
const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg); const ffmpegCommandLine = getFfCommandLine('ffmpeg', ffmpegArgs);
const ffmpegCommand = `ffmpeg ${ffmpegArgs.map(mapArg).join(' ')}`;
console.log(ffmpegCommand); console.log(ffmpegCommandLine);
appendFfmpegCommandLog(ffmpegCommand); appendFfmpegCommandLog(ffmpegCommandLine);
onProgress(0); onProgress(0);
@ -204,17 +268,15 @@ async function html5ify(filePath, outPath, encodeVideo, encodeAudio) {
'-y', outPath, '-y', outPath,
]; ];
console.log('ffmpeg', ffmpegArgs.join(' '));
const { stdout } = await runFfmpeg(ffmpegArgs); const { stdout } = await runFfmpeg(ffmpegArgs);
console.log(stdout); console.log(stdout);
await transferTimestamps(filePath, outPath); await transferTimestamps(filePath, outPath);
} }
async function getDuration(filePpath) { async function getDuration(filePath) {
// https://superuser.com/questions/650291/how-to-get-video-duration-in-seconds // https://superuser.com/questions/650291/how-to-get-video-duration-in-seconds
const { stdout } = await runFfprobe(['-i', filePpath, '-show_entries', 'format=duration', '-print_format', 'json']); const { stdout } = await runFfprobe(['-i', filePath, '-show_entries', 'format=duration', '-print_format', 'json']);
return parseFloat(JSON.parse(stdout).format.duration); return parseFloat(JSON.parse(stdout).format.duration);
} }
@ -234,8 +296,6 @@ async function html5ifyDummy(filePath, outPath) {
'-y', outPath, '-y', outPath,
]; ];
console.log('ffmpeg', ffmpegArgs.join(' '));
const { stdout } = await runFfmpeg(ffmpegArgs); const { stdout } = await runFfmpeg(ffmpegArgs);
console.log(stdout); console.log(stdout);
@ -389,8 +449,6 @@ async function extractStreams({ filePath, customOutDir, streams }) {
...streamArgs, ...streamArgs,
]; ];
console.log(ffmpegArgs);
// TODO progress // TODO progress
const { stdout } = await runFfmpeg(ffmpegArgs); const { stdout } = await runFfmpeg(ffmpegArgs);
console.log(stdout); console.log(stdout);
@ -418,7 +476,7 @@ async function renderFrame(timestamp, filePath, rotation) {
// console.time('ffmpeg'); // console.time('ffmpeg');
const ffmpegPath = await getPath('ffmpeg'); const ffmpegPath = await getPath('ffmpeg');
// console.timeEnd('ffmpeg'); // console.timeEnd('ffmpeg');
console.log('ffmpeg', args); // console.log('ffmpeg', args);
const { stdout } = await execa(ffmpegPath, args, { encoding: null }); const { stdout } = await execa(ffmpegPath, args, { encoding: null });
const blob = new Blob([stdout], { type: 'image/jpeg' }); const blob = new Blob([stdout], { type: 'image/jpeg' });
@ -458,4 +516,6 @@ module.exports = {
getStreamFps, getStreamFps,
isCuttingStart, isCuttingStart,
isCuttingEnd, isCuttingEnd,
readFrames,
getNextPrevKeyframe,
}; };

@ -123,12 +123,17 @@ module.exports = (app, mainWindow, newVersion) => {
{ {
role: 'help', role: 'help',
submenu: [ submenu: [
{
label: 'Help and Settings',
click() {
mainWindow.webContents.send('openHelp');
},
},
{ {
label: 'Learn More', label: 'Learn More',
click() { electron.shell.openExternal(homepage); }, click() { electron.shell.openExternal(homepage); },
}, },
...(process.platform === 'darwin' ? [] : [{ role: 'about' }]),
], ],
}); });
} }

@ -6,8 +6,9 @@ import { FiScissors } from 'react-icons/fi';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import Swal from 'sweetalert2'; import Swal from 'sweetalert2';
import Lottie from 'react-lottie'; import Lottie from 'react-lottie';
import { SideSheet, Button, Position, Table, SegmentedControl, Checkbox } from 'evergreen-ui'; import { SideSheet, Button, Position, Table, SegmentedControl, Checkbox, Select } from 'evergreen-ui';
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
import useDebounce from 'react-use/lib/useDebounce';
import fromPairs from 'lodash/fromPairs'; import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp'; import clamp from 'lodash/clamp';
@ -106,12 +107,15 @@ const App = memo(() => {
const [filePath, setFilePath] = useState(''); const [filePath, setFilePath] = useState('');
const [externalStreamFiles, setExternalStreamFiles] = useState([]); const [externalStreamFiles, setExternalStreamFiles] = useState([]);
const [detectedFps, setDetectedFps] = useState(); const [detectedFps, setDetectedFps] = useState();
const [mainStreams, setStreams] = useState([]); const [mainStreams, setMainStreams] = useState([]);
const [mainVideoStream, setMainVideoStream] = useState();
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({}); const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
const [zoom, setZoom] = useState(1); const [zoom, setZoom] = useState(1);
const [commandedTime, setCommandedTime] = useState(0); const [commandedTime, setCommandedTime] = useState(0);
const [debouncedCommandedTime, setDebouncedCommandedTime] = useState(0);
const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]);
const [neighbouringFrames, setNeighbouringFrames] = useState([]);
// Segment related state // Segment related state
const [currentSegIndex, setCurrentSegIndex] = useState(0); const [currentSegIndex, setCurrentSegIndex] = useState(0);
@ -122,6 +126,10 @@ const App = memo(() => {
100, 100,
); );
const [, cancelCommandedTimeDebounce] = useDebounce(() => {
setDebouncedCommandedTime(commandedTime);
}, 300, [commandedTime]);
// Preferences // Preferences
const [captureFormat, setCaptureFormat] = useState(configStore.get('captureFormat')); const [captureFormat, setCaptureFormat] = useState(configStore.get('captureFormat'));
@ -154,10 +162,11 @@ const App = memo(() => {
const timelineScrollerRef = useRef(); const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef(); const timelineScrollerSkipEventRef = useRef();
const lastSavedCutSegmentsRef = useRef(); const lastSavedCutSegmentsRef = useRef();
const readingKeyframesPromise = useRef();
function appendFfmpegCommandLog(command) { function appendFfmpegCommandLog(command) {
setFfmpegCommandLog(old => [...old, command]); setFfmpegCommandLog(old => [...old, { command, time: new Date() }]);
} }
function setCopyStreamIdsForPath(path, cb) { function setCopyStreamIdsForPath(path, cb) {
@ -178,21 +187,23 @@ const App = memo(() => {
}); });
} }
function seekAbs(val) { const seekAbs = useCallback((val) => {
const video = videoRef.current; const video = videoRef.current;
if (val == null || Number.isNaN(val)) return; if (val == null || Number.isNaN(val)) return;
let valRounded = val;
if (detectedFps) valRounded = Math.round(detectedFps * val) / detectedFps; // Round to nearest frame
let outVal = val; let outVal = valRounded;
if (outVal < 0) outVal = 0; if (outVal < 0) outVal = 0;
if (outVal > video.duration) outVal = video.duration; if (outVal > video.duration) outVal = video.duration;
video.currentTime = outVal; video.currentTime = outVal;
setCommandedTime(outVal); setCommandedTime(outVal);
} }, [detectedFps]);
const seekRel = useCallback((val) => { const seekRel = useCallback((val) => {
seekAbs(videoRef.current.currentTime + val); seekAbs(videoRef.current.currentTime + val);
}, []); }, [seekAbs]);
const shortStep = useCallback((dir) => { const shortStep = useCallback((dir) => {
seekRel((1 / (detectedFps || 60)) * dir); seekRel((1 / (detectedFps || 60)) * dir);
@ -200,6 +211,8 @@ const App = memo(() => {
const resetState = useCallback(() => { const resetState = useCallback(() => {
const video = videoRef.current; const video = videoRef.current;
cancelCommandedTimeDebounce();
setDebouncedCommandedTime(0);
setCommandedTime(0); setCommandedTime(0);
video.currentTime = 0; video.currentTime = 0;
video.playbackRate = 1; video.playbackRate = 1;
@ -224,11 +237,12 @@ const App = memo(() => {
setFilePath(''); // Setting video src="" prevents memory leak in chromium setFilePath(''); // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles([]); setExternalStreamFiles([]);
setDetectedFps(); setDetectedFps();
setStreams([]); setMainStreams([]);
setCopyStreamIdsByFile({}); setCopyStreamIdsByFile({});
setStreamsSelectorShown(false); setStreamsSelectorShown(false);
setZoom(1); setZoom(1);
}, [cutSegmentsHistory, setCutSegments]); setNeighbouringFrames([]);
}, [cutSegmentsHistory, setCutSegments, cancelCommandedTimeDebounce]);
useEffect(() => () => { useEffect(() => () => {
if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error); if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error);
@ -361,39 +375,58 @@ const App = memo(() => {
const getCurrentTime = useCallback(() => ( const getCurrentTime = useCallback(() => (
playing ? playerTime : commandedTime), [commandedTime, playerTime, playing]); playing ? playerTime : commandedTime), [commandedTime, playerTime, playing]);
const addCutSegment = useCallback(() => { // const getNextPrevKeyframe = useCallback((cutTime, next) => ffmpeg.getNextPrevKeyframe(neighbouringFrames, cutTime, next), [neighbouringFrames]);
const cutStartTime = currentCutSeg.start;
const cutEndTime = currentCutSeg.end;
if (cutStartTime === undefined && cutEndTime === undefined) return; const addCutSegment = useCallback(() => {
try {
const suggestedStart = getCurrentTime(); // Cannot add if prev seg is not finished
const suggestedEnd = suggestedStart + 10; if (currentCutSeg.start === undefined && currentCutSeg.end === undefined) return;
const cutSegmentsNew = [ const suggestedStart = getCurrentTime();
...cutSegments, /* if (keyframeCut) {
createSegment({ const keyframeAlignedStart = getNextPrevKeyframe(suggestedStart, true);
start: suggestedStart, if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart;
end: suggestedEnd <= duration ? suggestedEnd : undefined, } */
}),
]; let suggestedEnd = suggestedStart + 10;
if (suggestedEnd >= duration) {
setCutSegments(cutSegmentsNew); suggestedEnd = undefined;
setCurrentSegIndex(cutSegmentsNew.length - 1); } /* else if (keyframeCut) {
const keyframeAlignedEnd = getNextPrevKeyframe(suggestedEnd, false);
if (keyframeAlignedEnd != null) suggestedEnd = keyframeAlignedEnd;
} */
const cutSegmentsNew = [
...cutSegments,
createSegment({
start: suggestedStart,
end: suggestedEnd,
}),
];
setCutSegments(cutSegmentsNew);
setCurrentSegIndex(cutSegmentsNew.length - 1);
} catch (err) {
console.error(err);
}
}, [ }, [
currentCutSeg, cutSegments, getCurrentTime, duration, setCutSegments, currentCutSeg.start, currentCutSeg.end, cutSegments, getCurrentTime, duration, setCutSegments,
]); ]);
const setCutStart = useCallback(() => { const setCutStart = useCallback(() => {
// https://github.com/mifi/lossless-cut/issues/168 // https://github.com/mifi/lossless-cut/issues/168
// If we are after the end of the last segment in the timeline, // If we are after the end of the last segment in the timeline,
// add a new segment that starts at playerTime // add a new segment that starts at playerTime
if (currentCutSeg.end != null if (currentCutSeg.end != null && getCurrentTime() > currentCutSeg.end) {
&& getCurrentTime() > currentCutSeg.end) {
addCutSegment(); addCutSegment();
} else { } else {
try { try {
setCutTime('start', getCurrentTime()); const startTime = getCurrentTime();
/* if (keyframeCut) {
const keyframeAlignedCutTo = getNextPrevKeyframe(startTime, true);
if (keyframeAlignedCutTo != null) startTime = keyframeAlignedCutTo;
} */
setCutTime('start', startTime);
} catch (err) { } catch (err) {
errorToast(err.message); errorToast(err.message);
} }
@ -402,7 +435,13 @@ const App = memo(() => {
const setCutEnd = useCallback(() => { const setCutEnd = useCallback(() => {
try { try {
setCutTime('end', getCurrentTime()); const endTime = getCurrentTime();
/* if (keyframeCut) {
const keyframeAlignedCutTo = getNextPrevKeyframe(endTime, false);
if (keyframeAlignedCutTo != null) endTime = keyframeAlignedCutTo;
} */
setCutTime('end', endTime);
} catch (err) { } catch (err) {
errorToast(err.message); errorToast(err.message);
} }
@ -840,19 +879,17 @@ const App = memo(() => {
const { streams } = await ffmpeg.getAllStreams(fp); const { streams } = await ffmpeg.getAllStreams(fp);
// console.log('streams', streamsNew); // console.log('streams', streamsNew);
setStreams(streams); setMainStreams(streams);
setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [ setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [
stream.index, defaultProcessedCodecTypes.includes(stream.codec_type), stream.index, defaultProcessedCodecTypes.includes(stream.codec_type),
]))); ])));
streams.find((stream) => { const videoStream = streams.find(stream => stream.codec_type === 'video');
const streamFps = getStreamFps(stream); setMainVideoStream(videoStream);
if (streamFps != null) { if (videoStream) {
setDetectedFps(streamFps); const streamFps = getStreamFps(videoStream);
return true; if (streamFps != null) setDetectedFps(streamFps);
} }
return false;
});
setFileNameTitle(fp); setFileNameTitle(fp);
setFilePath(fp); setFilePath(fp);
@ -1099,6 +1136,10 @@ const App = memo(() => {
await loadEdlFile(filePaths[0]); await loadEdlFile(filePaths[0]);
} }
function openHelp() {
toggleHelp();
}
electron.ipcRenderer.on('file-opened', fileOpened); electron.ipcRenderer.on('file-opened', fileOpened);
electron.ipcRenderer.on('close-file', closeFile); electron.ipcRenderer.on('close-file', closeFile);
electron.ipcRenderer.on('html5ify', html5ify); electron.ipcRenderer.on('html5ify', html5ify);
@ -1109,6 +1150,7 @@ const App = memo(() => {
electron.ipcRenderer.on('redo', redo); electron.ipcRenderer.on('redo', redo);
electron.ipcRenderer.on('importEdlFile', importEdlFile); electron.ipcRenderer.on('importEdlFile', importEdlFile);
electron.ipcRenderer.on('exportEdlFile', exportEdlFile); electron.ipcRenderer.on('exportEdlFile', exportEdlFile);
electron.ipcRenderer.on('openHelp', openHelp);
return () => { return () => {
electron.ipcRenderer.removeListener('file-opened', fileOpened); electron.ipcRenderer.removeListener('file-opened', fileOpened);
@ -1121,6 +1163,7 @@ const App = memo(() => {
electron.ipcRenderer.removeListener('redo', redo); electron.ipcRenderer.removeListener('redo', redo);
electron.ipcRenderer.removeListener('importEdlFile', importEdlFile); electron.ipcRenderer.removeListener('importEdlFile', importEdlFile);
electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile); electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile);
electron.ipcRenderer.removeListener('openHelp', openHelp);
}; };
}, [ }, [
load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath, load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath,
@ -1215,9 +1258,10 @@ const App = memo(() => {
<option key={f} value={f}>{f} - {name}</option> <option key={f} value={f}>{f} - {name}</option>
)); ));
} }
function renderOutFmt(style = {}) { function renderOutFmt(props) {
return ( return (
<select style={style} defaultValue="" value={fileFormat} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))}> // eslint-disable-next-line react/jsx-props-no-spreading
<Select defaultValue="" value={fileFormat} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))} {...props}>
<option key="disabled1" value="" disabled>Format</option> <option key="disabled1" value="" disabled>Format</option>
{detectedFileFormat && ( {detectedFileFormat && (
@ -1231,19 +1275,20 @@ const App = memo(() => {
<option key="disabled3" value="" disabled>--- All formats: ---</option> <option key="disabled3" value="" disabled>--- All formats: ---</option>
{renderFormatOptions(otherFormatsMap)} {renderFormatOptions(otherFormatsMap)}
</select> </Select>
); );
} }
function renderCaptureFormatButton() { function renderCaptureFormatButton(props) {
return ( return (
<button <Button
type="button"
title="Capture frame format" title="Capture frame format"
onClick={withBlur(toggleCaptureFormat)} onClick={withBlur(toggleCaptureFormat)}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
> >
{captureFormat} {captureFormat}
</button> </Button>
); );
} }
@ -1266,12 +1311,9 @@ const App = memo(() => {
This is where working files, exported files, project files (CSV) are stored. This is where working files, exported files, project files (CSV) are stored.
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<button <Button onClick={setOutputDir}>
type="button"
onClick={setOutputDir}
>
{customOutDir ? 'Custom working directory' : 'Same directory as input file'} {customOutDir ? 'Custom working directory' : 'Same directory as input file'}
</button> </Button>
<div>{customOutDir}</div> <div>{customOutDir}</div>
</Table.TextCell> </Table.TextCell>
</Row> </Row>
@ -1290,8 +1332,8 @@ const App = memo(() => {
<Row> <Row>
<KeyCell> <KeyCell>
Keyframe cut mode<br /> Keyframe cut mode<br />
<b>Nearest keyframe</b>: Cut at the nearest keyframe (not accurate time.)<br /> <b>Nearest keyframe</b>: Cut at the nearest keyframe (not accurate time.) Equiv to <i>ffmpeg -ss -i ...</i><br />
<b>Normal cut</b>: Accurate time but could leave an empty portion at the beginning of the video.<br /> <b>Normal cut</b>: Accurate time but could leave an empty portion at the beginning of the video. Equiv to <i>ffmpeg -i -ss ...</i><br />
</KeyCell> </KeyCell>
<Table.TextCell> <Table.TextCell>
<SegmentedControl <SegmentedControl
@ -1389,6 +1431,24 @@ const App = memo(() => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
useEffect(() => {
async function run() {
if (!filePath || debouncedCommandedTime == null || !mainVideoStream || readingKeyframesPromise.current) return;
try {
const promise = ffmpeg.readFrames({ filePath, aroundTime: debouncedCommandedTime, stream: mainVideoStream.index });
readingKeyframesPromise.current = promise;
const newFrames = await promise;
// console.log(newFrames);
setNeighbouringFrames(newFrames);
} catch (err) {
console.error('Failed to read keyframes', err);
} finally {
readingKeyframesPromise.current = undefined;
}
}
run();
}, [filePath, debouncedCommandedTime, mainVideoStream]);
const topBarHeight = '2rem'; const topBarHeight = '2rem';
const bottomBarHeight = '6rem'; const bottomBarHeight = '6rem';
@ -1513,32 +1573,34 @@ const App = memo(() => {
{filePath && ( {filePath && (
<Fragment> <Fragment>
<button <Button
type="button" iconBefore={customOutDir ? 'folder-open' : undefined}
height={20}
onClick={withBlur(setOutputDir)} onClick={withBlur(setOutputDir)}
title={customOutDir} title={customOutDir}
> >
{`Working dir ${customOutDir ? 'set' : 'unset'}`} {`Working dir ${customOutDir ? 'set' : 'unset'}`}
</button> </Button>
{renderOutFmt({ width: 60 })} <div style={{ width: 60 }}>{renderOutFmt({ height: 20 })}</div>
<button <Button
style={{ opacity: cutSegments.length < 2 ? 0.4 : undefined }} height={20}
type="button" style={{ opacity: outSegments && outSegments.length < 2 ? 0.4 : undefined }}
title={autoMerge ? 'Auto merge segments to one file after export' : 'Export to separate files'} title={autoMerge ? 'Auto merge segments to one file after export' : 'Export to separate files'}
onClick={withBlur(toggleAutoMerge)} onClick={withBlur(toggleAutoMerge)}
> >
<AutoMergeIcon /> {autoMerge ? 'Merge cuts' : 'Separate files'} <AutoMergeIcon /> {autoMerge ? 'Merge cuts' : 'Separate files'}
</button> </Button>
<button <Button
type="button" height={20}
title={`Cut mode ${keyframeCut ? 'nearest keyframe cut' : 'normal cut'}`} iconBefore={keyframeCut ? 'key' : undefined}
title={`Cut mode is ${keyframeCut ? 'keyframe cut' : 'normal cut'}`}
onClick={withBlur(toggleKeyframeCut)} onClick={withBlur(toggleKeyframeCut)}
> >
{keyframeCut ? 'Keyframe cut' : 'Normal cut'} {keyframeCut ? 'Keyframe cut' : 'Normal cut'}
</button> </Button>
</Fragment> </Fragment>
)} )}
@ -1718,6 +1780,10 @@ const App = memo(() => {
invertCutSegments={invertCutSegments} invertCutSegments={invertCutSegments}
/> />
))} ))}
{mainVideoStream && neighbouringFrames.filter(f => f.keyframe).map((f) => (
<div key={f.time} style={{ position: 'absolute', top: 0, bottom: 0, left: `${(f.time / duration) * 100}%`, marginLeft: -1, width: 1, background: 'rgba(0,0,0,1)' }} />
))}
</div> </div>
</div> </div>
@ -1802,14 +1868,14 @@ const App = memo(() => {
<div className="left-menu no-user-select" style={{ position: 'absolute', left: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}> <div className="left-menu no-user-select" style={{ position: 'absolute', left: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
{renderInvertCutButton()} {renderInvertCutButton()}
<select style={{ width: 80, margin: '0 10px' }} value={zoom.toString()} title="Zoom" onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}> <Select height={20} style={{ width: 80, margin: '0 10px' }} value={zoom.toString()} title="Zoom" onChange={withBlur(e => setZoom(parseInt(e.target.value, 10)))}>
{Array(10).fill().map((unused, z) => { {Array(10).fill().map((unused, z) => {
const val = 2 ** z; const val = 2 ** z;
return ( return (
<option key={val} value={String(val)}>Zoom {val}x</option> <option key={val} value={String(val)}>Zoom {val}x</option>
); );
})} })}
</select> </Select>
</div> </div>
<div className="right-menu no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}> <div className="right-menu no-user-select" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
@ -1832,7 +1898,7 @@ const App = memo(() => {
role="button" role="button"
/> />
{renderCaptureFormatButton()} {renderCaptureFormatButton({ height: 20 })}
<IoIosCamera <IoIosCamera
style={{ paddingLeft: 5, paddingRight: 15 }} style={{ paddingLeft: 5, paddingRight: 15 }}

Loading…
Cancel
Save