You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
lossless-cut/src/renderer.jsx

1834 lines
60 KiB
JavaScript

import React, { memo, useEffect, useState, useCallback, useRef, Fragment } 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';
import { FiScissors } from 'react-icons/fi';
import { AnimatePresence, motion } from 'framer-motion';
import Swal from 'sweetalert2';
import Lottie from 'react-lottie';
import { SideSheet, Button, Position } from 'evergreen-ui';
import { useStateWithHistory } from 'react-use/lib/useStateWithHistory';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
import cloneDeep from 'lodash/cloneDeep';
import sortBy from 'lodash/sortBy';
import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';
import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg';
import InverseCutSegment from './InverseCutSegment';
import StreamsSelector from './StreamsSelector';
import { loadMifiLink } from './mifi';
import loadingLottie from './7077-magic-flow.json';
const isDev = require('electron-is-dev');
const electron = require('electron'); // eslint-disable-line
const Mousetrap = require('mousetrap');
const Hammer = require('react-hammerjs').default;
const trash = require('trash');
const uuid = require('uuid');
const ReactDOM = require('react-dom');
const { default: PQueue } = require('p-queue');
const { unlink, exists } = require('fs-extra');
const { showMergeDialog, showOpenAndMergeDialog } = require('./merge/merge');
const allOutFormats = require('./outFormats');
const captureFrame = require('./capture-frame');
const ffmpeg = require('./ffmpeg');
const configStore = require('./store');
const edlStore = require('./edlStore');
const { defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd } = ffmpeg;
const {
getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
promptTimeOffset, generateColor, getOutDir,
} = require('./util');
const { dialog } = electron.remote;
function withBlur(cb) {
return (e) => {
cb(e);
e.target.blur();
};
}
function createSegment({ start, end, name } = {}) {
return {
start,
end,
name: name || '',
color: generateColor(),
uuid: uuid.v4(),
};
}
const createInitialCutSegments = () => [createSegment()];
const dragPreventer = ev => {
ev.preventDefault();
};
function doesPlayerSupportFile(streams) {
// TODO improve, whitelist supported codecs instead
return !streams.find(s => ['hevc', 'prores'].includes(s.codec_name));
// return true;
}
const queue = new PQueue({ concurrency: 1 });
const App = memo(() => {
// Per project state
const [framePath, setFramePath] = useState();
const [html5FriendlyPath, setHtml5FriendlyPath] = useState();
const [working, setWorking] = useState(false);
const [dummyVideoPath, setDummyVideoPath] = useState(false);
const [playing, setPlaying] = useState(false);
const [playerTime, setPlayerTime] = useState();
const [duration, setDuration] = useState();
const [fileFormat, setFileFormat] = useState();
const [detectedFileFormat, setDetectedFileFormat] = useState();
const [rotation, setRotation] = useState(360);
const [cutProgress, setCutProgress] = useState();
const [startTimeOffset, setStartTimeOffset] = useState(0);
const [rotationPreviewRequested, setRotationPreviewRequested] = useState(false);
const [filePath, setFilePath] = useState('');
const [externalStreamFiles, setExternalStreamFiles] = useState([]);
const [detectedFps, setDetectedFps] = useState();
const [mainStreams, setStreams] = useState([]);
const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({});
const [streamsSelectorShown, setStreamsSelectorShown] = useState(false);
const [zoom, setZoom] = useState(1);
const [commandedTime, setCommandedTime] = useState(0);
const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]);
// Segment related state
const [currentSegIndex, setCurrentSegIndex] = useState(0);
const [cutStartTimeManual, setCutStartTimeManual] = useState();
const [cutEndTimeManual, setCutEndTimeManual] = useState();
const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory(
createInitialCutSegments(),
100,
);
// Preferences
const [captureFormat, setCaptureFormat] = useState(configStore.get('captureFormat'));
useEffect(() => configStore.set('captureFormat', captureFormat), [captureFormat]);
const [customOutDir, setCustomOutDir] = useState(configStore.get('customOutDir'));
useEffect(() => (customOutDir === undefined ? configStore.delete('customOutDir') : configStore.set('customOutDir', customOutDir)), [customOutDir]);
const [keyframeCut, setKeyframeCut] = useState(configStore.get('keyframeCut'));
useEffect(() => configStore.set('keyframeCut', keyframeCut), [keyframeCut]);
const [autoMerge, setAutoMerge] = useState(configStore.get('autoMerge'));
useEffect(() => configStore.set('autoMerge', autoMerge), [autoMerge]);
const [timecodeShowFrames, setTimecodeShowFrames] = useState(configStore.get('timecodeShowFrames'));
useEffect(() => configStore.set('timecodeShowFrames', timecodeShowFrames), [timecodeShowFrames]);
const [invertCutSegments, setInvertCutSegments] = useState(configStore.get('invertCutSegments'));
useEffect(() => configStore.set('invertCutSegments', invertCutSegments), [invertCutSegments]);
const [autoExportExtraStreams, setAutoExportExtraStreams] = useState(configStore.get('autoExportExtraStreams'));
useEffect(() => configStore.set('autoExportExtraStreams', autoExportExtraStreams), [autoExportExtraStreams]);
const [askBeforeClose, setAskBeforeClose] = useState(configStore.get('askBeforeClose'));
useEffect(() => configStore.set('askBeforeClose', askBeforeClose), [askBeforeClose]);
const [muted, setMuted] = useState(configStore.get('muted'));
useEffect(() => configStore.set('muted', muted), [muted]);
const [autoSaveProjectFile, setAutoSaveProjectFile] = useState(configStore.get('autoSaveProjectFile'));
useEffect(() => configStore.set('autoSaveProjectFile', autoSaveProjectFile), [autoSaveProjectFile]);
// Global state
const [helpVisible, setHelpVisible] = useState(false);
const [mifiLink, setMifiLink] = useState();
const videoRef = useRef();
const timelineWrapperRef = useRef();
const timelineScrollerRef = useRef();
const timelineScrollerSkipEventRef = useRef();
const lastSavedCutSegmentsRef = useRef();
function appendFfmpegCommandLog(command) {
setFfmpegCommandLog(old => [...old, command]);
}
function setCopyStreamIdsForPath(path, cb) {
setCopyStreamIdsByFile((old) => {
const oldIds = old[path] || {};
return ({ ...old, [path]: cb(oldIds) });
});
}
function toggleCopyStreamId(path, index) {
setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[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;
let outVal = val;
if (outVal < 0) outVal = 0;
if (outVal > video.duration) outVal = video.duration;
video.currentTime = outVal;
setCommandedTime(outVal);
}
const seekRel = useCallback((val) => {
seekAbs(videoRef.current.currentTime + val);
}, []);
const shortStep = useCallback((dir) => {
seekRel((1 / 60) * dir);
}, [seekRel]);
const resetState = useCallback(() => {
const video = videoRef.current;
setCommandedTime(0);
video.currentTime = 0;
video.playbackRate = 1;
setFileNameTitle();
setFramePath();
setHtml5FriendlyPath();
setDummyVideoPath();
setWorking(false);
setPlaying(false);
setDuration();
cutSegmentsHistory.go(0);
setCutSegments(createInitialCutSegments()); // TODO this will cause two history items
setCutStartTimeManual();
setCutEndTimeManual();
setFileFormat();
setDetectedFileFormat();
setRotation(360);
setCutProgress();
setStartTimeOffset(0);
setRotationPreviewRequested(false);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles([]);
setDetectedFps();
setStreams([]);
setCopyStreamIdsByFile({});
setStreamsSelectorShown(false);
setZoom(1);
}, [cutSegmentsHistory, setCutSegments]);
useEffect(() => () => {
if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error);
}, [dummyVideoPath]);
const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath);
// Because segments could have undefined start / end
// (meaning extend to start of timeline or end duration)
function getSegApparentStart(seg) {
const time = seg.start;
return time !== undefined ? time : 0;
}
const getSegApparentEnd = useCallback((seg) => {
const time = seg.end;
if (time !== undefined) return time;
if (duration !== undefined) return duration;
return 0; // Haven't gotten duration yet
}, [duration]);
const cleanCutSegments = (cs) => cs.map((seg) => ({
start: seg.start,
end: seg.end,
name: seg.name,
}));
const apparentCutSegments = cutSegments.map(cutSegment => ({
...cutSegment,
start: getSegApparentStart(cutSegment),
end: getSegApparentEnd(cutSegment),
}));
const invalidSegUuids = apparentCutSegments
.filter(cutSegment => cutSegment.start >= cutSegment.end)
.map(cutSegment => cutSegment.uuid);
const haveInvalidSegs = invalidSegUuids.length > 0;
const currentSegIndexSafe = Math.min(currentSegIndex, cutSegments.length - 1);
const currentCutSeg = cutSegments[currentSegIndexSafe];
const currentApparentCutSeg = apparentCutSegments[currentSegIndexSafe];
const areWeCutting = apparentCutSegments.length > 1
|| isCuttingStart(currentApparentCutSeg.start)
|| isCuttingEnd(currentApparentCutSeg.end, duration);
const sortedCutSegments = sortBy(apparentCutSegments, 'start');
const inverseCutSegments = (() => {
if (haveInvalidSegs) return undefined;
if (apparentCutSegments.length < 1) return undefined;
const foundOverlap = sortedCutSegments.some((cutSegment, i) => {
if (i === 0) return false;
return sortedCutSegments[i - 1].end > cutSegment.start;
});
if (foundOverlap) return undefined;
if (duration == null) return undefined;
const ret = [];
if (sortedCutSegments[0].start > 0) {
ret.push({
start: 0,
end: sortedCutSegments[0].start,
});
}
sortedCutSegments.forEach((cutSegment, i) => {
if (i === 0) return;
ret.push({
start: sortedCutSegments[i - 1].end,
end: cutSegment.start,
});
});
const last = sortedCutSegments[sortedCutSegments.length - 1];
if (last.end < duration) {
ret.push({
start: last.end,
end: duration,
});
}
return ret;
})();
const setCutTime = useCallback((type, time) => {
const currentSeg = currentCutSeg;
if (type === 'start' && time >= getSegApparentEnd(currentSeg)) {
throw new Error('Start time must precede end time');
}
if (type === 'end' && time <= getSegApparentStart(currentSeg)) {
throw new Error('Start time must precede end time');
}
const cloned = cloneDeep(cutSegments);
cloned[currentSegIndexSafe][type] = Math.min(Math.max(time, 0), duration);
setCutSegments(cloned);
}, [
currentSegIndexSafe, getSegApparentEnd, cutSegments, currentCutSeg, setCutSegments, duration,
]);
const setCurrentSegmentName = (name) => {
const cloned = cloneDeep(cutSegments);
cloned[currentSegIndexSafe].name = name;
setCutSegments(cloned);
};
const formatTimecode = useCallback((sec) => formatDuration({
seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined,
}), [detectedFps, timecodeShowFrames]);
const getCurrentTime = useCallback(() => (
playing ? playerTime : commandedTime), [commandedTime, playerTime, playing]);
const addCutSegment = useCallback(() => {
const cutStartTime = currentCutSeg.start;
const cutEndTime = currentCutSeg.end;
if (cutStartTime === undefined && cutEndTime === undefined) return;
const suggestedStart = getCurrentTime();
const suggestedEnd = suggestedStart + 10;
const cutSegmentsNew = [
...cutSegments,
createSegment({
start: suggestedStart,
end: suggestedEnd <= duration ? suggestedEnd : undefined,
}),
];
setCutSegments(cutSegmentsNew);
setCurrentSegIndex(cutSegmentsNew.length - 1);
}, [
currentCutSeg, cutSegments, getCurrentTime, duration, setCutSegments,
]);
const setCutStart = useCallback(() => {
// https://github.com/mifi/lossless-cut/issues/168
// If we are after the end of the last segment in the timeline,
// add a new segment that starts at playerTime
if (currentCutSeg.end != null
&& getCurrentTime() > currentCutSeg.end) {
addCutSegment();
} else {
try {
setCutTime('start', getCurrentTime());
} catch (err) {
errorToast(err.message);
}
}
}, [setCutTime, getCurrentTime, currentCutSeg, addCutSegment]);
const setCutEnd = useCallback(() => {
try {
setCutTime('end', getCurrentTime());
} catch (err) {
errorToast(err.message);
}
}, [setCutTime, getCurrentTime]);
async function setOutputDir() {
const { filePaths } = await dialog.showOpenDialog({ properties: ['openDirectory'] });
setCustomOutDir((filePaths && filePaths.length === 1) ? filePaths[0] : undefined);
}
const fileUri = (dummyVideoPath || html5FriendlyPath || filePath || '').replace(/#/g, '%23');
const outputDir = getOutDir(customOutDir, filePath);
const getEdlFilePath = useCallback((fp) => getOutPath(customOutDir, fp, 'llc-edl.csv'), [customOutDir]);
const edlFilePath = getEdlFilePath(filePath);
useEffect(() => {
async function save() {
if (!edlFilePath) return;
try {
if (!autoSaveProjectFile) return;
// Initial state? don't save
if (isEqual(cleanCutSegments(cutSegments),
cleanCutSegments(createInitialCutSegments()))) return;
if (lastSavedCutSegmentsRef.current
&& isEqual(cleanCutSegments(lastSavedCutSegmentsRef.current),
cleanCutSegments(cutSegments))) {
// console.log('Seg state didn\'t change, skipping save');
return;
}
await edlStore.save(edlFilePath, cutSegments);
lastSavedCutSegmentsRef.current = cutSegments;
} catch (err) {
errorToast('Failed to save CSV');
console.error('Failed to save CSV', err);
}
}
save();
}, [cutSegments, edlFilePath, autoSaveProjectFile]);
// 360 means we don't modify rotation
const isRotationSet = rotation !== 360;
const effectiveRotation = isRotationSet ? rotation : undefined;
const rotationStr = `${rotation}°`;
useEffect(() => {
async function throttledRender() {
if (queue.size < 2) {
queue.add(async () => {
if (!frameRenderEnabled) return;
if (playerTime == null || !filePath) return;
try {
const framePathNew = await ffmpeg.renderFrame(playerTime, filePath, effectiveRotation);
setFramePath(framePathNew);
} catch (err) {
console.error(err);
}
});
}
await queue.onIdle();
}
throttledRender();
}, [
filePath, playerTime, frameRenderEnabled, effectiveRotation,
]);
// Cleanup old frames
useEffect(() => () => URL.revokeObjectURL(framePath), [framePath]);
function onPlayingChange(val) {
setPlaying(val);
if (!val) {
videoRef.current.playbackRate = 1;
setCommandedTime(videoRef.current.currentTime);
}
}
function onTimeUpdate(e) {
const { currentTime } = e.target;
if (playerTime === currentTime) return;
setRotationPreviewRequested(false); // Reset this
setPlayerTime(currentTime);
}
function increaseRotation() {
setRotation((r) => (r + 90) % 450);
setRotationPreviewRequested(true);
}
const offsetCurrentTime = (getCurrentTime() || 0) + startTimeOffset;
const mergeFiles = useCallback(async ({ paths, allStreams }) => {
try {
setWorking(true);
// console.log('merge', paths);
await ffmpeg.mergeAnyFiles({
customOutDir, paths, allStreams,
});
} catch (err) {
errorToast('Failed to merge files. Make sure they are all of the exact same format and codecs');
console.error('Failed to merge files', err);
} finally {
setWorking(false);
}
}, [customOutDir]);
const toggleCaptureFormat = () => setCaptureFormat(f => (f === 'png' ? 'jpeg' : 'png'));
const toggleKeyframeCut = () => setKeyframeCut(val => !val);
const toggleAutoMerge = () => setAutoMerge(val => !val);
const isCopyingStreamId = useCallback((path, streamId) => (
!!(copyStreamIdsByFile[path] || {})[streamId]
), [copyStreamIdsByFile]);
const copyAnyAudioTrack = mainStreams.some(stream => isCopyingStreamId(filePath, stream.index) && stream.codec_type === 'audio');
// Streams that are not copy enabled by default
const extraStreams = mainStreams
.filter((stream) => !defaultProcessedCodecTypes.includes(stream.codec_type));
// Extra streams that the user has not selected for copy
const nonCopiedExtraStreams = extraStreams
.filter((stream) => !isCopyingStreamId(filePath, stream.index));
const exportExtraStreams = autoExportExtraStreams && nonCopiedExtraStreams.length > 0;
const copyStreamIds = Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({
path,
streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]),
}));
const numStreamsToCopy = copyStreamIds
.reduce((acc, { streamIds }) => acc + streamIds.length, 0);
const numStreamsTotal = [
...mainStreams,
...flatMap(Object.values(externalStreamFiles), ({ streams }) => streams),
].length;
function toggleStripAudio() {
setCopyStreamIdsForPath(filePath, (old) => {
const newCopyStreamIds = { ...old };
mainStreams.forEach((stream) => {
if (stream.codec_type === 'audio') newCopyStreamIds[stream.index] = !copyAnyAudioTrack;
});
return newCopyStreamIds;
});
}
const removeCutSegment = useCallback(() => {
if (cutSegments.length < 2) return;
const cutSegmentsNew = [...cutSegments];
cutSegmentsNew.splice(currentSegIndexSafe, 1);
setCutSegments(cutSegmentsNew);
}, [currentSegIndexSafe, cutSegments, setCutSegments]);
const jumpCutStart = () => seekAbs(currentApparentCutSeg.start);
const jumpCutEnd = () => seekAbs(currentApparentCutSeg.end);
function handleTap(e) {
const target = timelineWrapperRef.current;
const rect = target.getBoundingClientRect();
const relX = e.srcEvent.pageX - (rect.left + document.body.scrollLeft);
if (duration) seekAbs((relX / target.offsetWidth) * duration);
}
const durationSafe = duration || 1;
const currentTimeWidth = 1;
// Prevent it from overflowing (and causing scroll) when end of timeline
const calculateTimelinePos = (time) => (time !== undefined && time < durationSafe ? `${(time / durationSafe) * 100}%` : undefined);
const currentTimePos = calculateTimelinePos(playerTime);
const commandedTimePos = calculateTimelinePos(commandedTime);
const zoomed = zoom > 1;
useEffect(() => {
const { currentTime } = videoRef.current;
timelineScrollerSkipEventRef.current = true;
if (zoom > 1) {
timelineScrollerRef.current.scrollLeft = (currentTime / durationSafe)
* (timelineWrapperRef.current.offsetWidth - timelineScrollerRef.current.offsetWidth);
}
}, [zoom, durationSafe]);
function onTimelineScroll(e) {
if (timelineScrollerSkipEventRef.current) {
timelineScrollerSkipEventRef.current = false;
return;
}
if (!zoomed) return;
seekAbs((((e.target.scrollLeft + (timelineScrollerRef.current.offsetWidth / 2))
/ timelineWrapperRef.current.offsetWidth) * duration));
}
function onWheel(e) {
if (!zoomed) seekRel((e.deltaX + e.deltaY) / 15);
}
function showUnsupportedFileMessage() {
toast.fire({ timer: 10000, icon: 'warning', title: 'This video is not natively supported', text: 'This means that there is no audio in the preview and it has low quality. The final export operation will however be lossless and contains audio!' });
}
const createDummyVideo = useCallback(async (fp) => {
const html5ifiedDummyPathDummy = getOutPath(customOutDir, fp, 'html5ified-dummy.mkv');
await ffmpeg.html5ifyDummy(fp, html5ifiedDummyPathDummy);
setDummyVideoPath(html5ifiedDummyPathDummy);
setHtml5FriendlyPath();
showUnsupportedFileMessage();
}, [customOutDir]);
const tryCreateDummyVideo = useCallback(async () => {
try {
if (working) return;
setWorking(true);
await createDummyVideo(filePath);
} catch (err) {
console.error(err);
errorToast('Failed to playback this file. Try to convert to friendly format from the menu');
} finally {
setWorking(false);
}
}, [createDummyVideo, filePath, working]);
const playCommand = useCallback(() => {
const video = videoRef.current;
if (playing) return video.pause();
return video.play().catch((err) => {
console.error(err);
if (err.name === 'NotSupportedError') {
console.log('NotSupportedError, trying to create dummy');
tryCreateDummyVideo(filePath);
}
});
}, [playing, filePath, tryCreateDummyVideo]);
const deleteSource = useCallback(async () => {
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? ${filePath}`)) return;
try {
setWorking(true);
await trash(filePath);
if (html5FriendlyPath) await trash(html5FriendlyPath);
} catch (err) {
toast.fire({ icon: 'error', title: `Failed to trash source file: ${err.message}` });
} finally {
resetState();
}
}, [filePath, html5FriendlyPath, resetState, working]);
const cutClick = useCallback(async () => {
if (working) {
errorToast('I\'m busy');
return;
}
if (haveInvalidSegs) {
errorToast('Start time must be before end time');
return;
}
if (numStreamsToCopy === 0) {
errorToast('No tracks to export!');
return;
}
const segments = invertCutSegments ? inverseCutSegments : apparentCutSegments;
if (!segments) {
errorToast('No segments to export!');
return;
}
const ffmpegSegments = segments.map((seg) => ({
cutFrom: seg.start,
cutTo: seg.end,
}));
if (segments.length < 1) {
errorToast('No segments to export');
return;
}
try {
setWorking(true);
const outFiles = await ffmpeg.cutMultiple({
customOutDir,
filePath,
outFormat: fileFormat,
isOutFormatUserSelected: fileFormat !== detectedFileFormat,
videoDuration: duration,
rotation: effectiveRotation,
copyStreamIds,
keyframeCut,
segments: ffmpegSegments,
onProgress: setCutProgress,
appendFfmpegCommandLog,
});
if (outFiles.length > 1 && autoMerge) {
setCutProgress(0); // TODO implement progress
await ffmpeg.autoMergeSegments({
customOutDir,
sourceFile: filePath,
segmentPaths: outFiles,
});
}
if (exportExtraStreams) {
try {
await ffmpeg.extractStreams({
filePath, customOutDir, streams: nonCopiedExtraStreams,
});
} catch (err) {
console.error('Extra stream export failed', err);
}
}
toast.fire({ timer: 5000, icon: 'success', title: `Export completed! Output file(s) can be found at: ${outputDir}.${exportExtraStreams ? ' Extra unprocessable stream(s) exported as separate files.' : ''}` });
} catch (err) {
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
if (err.code === 1 || err.code === 'ENOENT') {
toast.fire({ icon: 'error', title: `Whoops! ffmpeg was unable to export this video. Try one of the following before exporting again:\n1. Select a different output format from the ${fileFormat} button (matroska takes almost everything).\n2. Exclude unnecessary tracks\n3. Try "Normal cut" and "Keyframe cut"`, timer: 10000 });
return;
}
showFfmpegFail(err);
} finally {
setWorking(false);
}
}, [
effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments,
working, duration, filePath, keyframeCut, detectedFileFormat,
autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy,
exportExtraStreams, nonCopiedExtraStreams, outputDir,
]);
// TODO use ffmpeg to capture frame
const capture = useCallback(async () => {
if (!filePath) return;
if (html5FriendlyPath || dummyVideoPath) {
errorToast('Capture frame from this video not yet implemented');
return;
}
try {
await captureFrame(customOutDir, filePath, videoRef.current, playerTime, captureFormat);
} catch (err) {
console.error(err);
errorToast('Failed to capture frame');
}
}, [filePath, playerTime, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath]);
const changePlaybackRate = useCallback((dir) => {
const video = videoRef.current;
if (!playing) {
video.playbackRate = 0.5; // dir * 0.5;
video.play();
} else {
const newRate = video.playbackRate + (dir * 0.15);
video.playbackRate = clamp(newRate, 0.05, 16);
}
}, [playing]);
const getHtml5ifiedPath = useCallback((fp, type) => getOutPath(customOutDir, fp, `html5ified-${type}.mp4`), [customOutDir]);
const checkExistingHtml5FriendlyFile = useCallback(async (fp, speed) => {
const existing = getHtml5ifiedPath(fp, speed);
const ret = existing && await exists(existing);
if (ret) {
setHtml5FriendlyPath(existing);
showUnsupportedFileMessage();
}
return ret;
}, [getHtml5ifiedPath]);
const loadEdlFile = useCallback(async (edlPath) => {
try {
const storedEdl = await edlStore.load(edlPath);
const allRowsValid = storedEdl
.every(row => row.start === undefined || row.end === undefined || row.start < row.end);
if (!allRowsValid) {
throw new Error('Invalid start or end values for one or more segments');
}
cutSegmentsHistory.go(0);
setCutSegments(storedEdl.map(createSegment));
} catch (err) {
if (err.code !== 'ENOENT') {
console.error('EDL load failed', err);
errorToast(`Failed to load EDL file (${err.message})`);
}
}
}, [cutSegmentsHistory, setCutSegments]);
const load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested });
if (working) {
errorToast('Tried to load file while busy');
return;
}
resetState();
setWorking(true);
try {
const ff = await ffmpeg.getFormat(fp);
if (!ff) {
errorToast('Unsupported file');
return;
}
const { streams } = await ffmpeg.getAllStreams(fp);
// console.log('streams', streamsNew);
setStreams(streams);
setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [
stream.index, defaultProcessedCodecTypes.includes(stream.codec_type),
])));
streams.find((stream) => {
const streamFps = getStreamFps(stream);
if (streamFps != null) {
setDetectedFps(streamFps);
return true;
}
return false;
});
setFileNameTitle(fp);
setFilePath(fp);
setFileFormat(ff);
setDetectedFileFormat(ff);
if (html5FriendlyPathRequested) {
setHtml5FriendlyPath(html5FriendlyPathRequested);
showUnsupportedFileMessage();
} else if (
!(await checkExistingHtml5FriendlyFile(fp, 'slow-audio') || await checkExistingHtml5FriendlyFile(fp, 'slow') || await checkExistingHtml5FriendlyFile(fp, 'fast'))
&& !doesPlayerSupportFile(streams)
) {
await createDummyVideo(fp);
}
await loadEdlFile(getEdlFilePath(fp));
} catch (err) {
if (err.code === 1 || err.code === 'ENOENT') {
errorToast('Unsupported file');
return;
}
showFfmpegFail(err);
} finally {
setWorking(false);
}
}, [
resetState, working, createDummyVideo, checkExistingHtml5FriendlyFile, loadEdlFile,
getEdlFilePath,
]);
const toggleHelp = () => setHelpVisible(val => !val);
useEffect(() => {
Mousetrap.bind('space', () => playCommand());
Mousetrap.bind('k', () => playCommand());
Mousetrap.bind('j', () => changePlaybackRate(-1));
Mousetrap.bind('l', () => changePlaybackRate(1));
Mousetrap.bind('left', () => seekRel(-1));
Mousetrap.bind('right', () => seekRel(1));
Mousetrap.bind('.', () => shortStep(1));
Mousetrap.bind(',', () => shortStep(-1));
Mousetrap.bind('c', () => capture());
Mousetrap.bind('e', () => cutClick());
Mousetrap.bind('i', () => setCutStart());
Mousetrap.bind('o', () => setCutEnd());
Mousetrap.bind('h', () => toggleHelp());
Mousetrap.bind('+', () => addCutSegment());
Mousetrap.bind('backspace', () => removeCutSegment());
Mousetrap.bind('d', () => deleteSource());
return () => {
Mousetrap.unbind('space');
Mousetrap.unbind('k');
Mousetrap.unbind('j');
Mousetrap.unbind('l');
Mousetrap.unbind('left');
Mousetrap.unbind('right');
Mousetrap.unbind('.');
Mousetrap.unbind(',');
Mousetrap.unbind('c');
Mousetrap.unbind('e');
Mousetrap.unbind('i');
Mousetrap.unbind('o');
Mousetrap.unbind('h');
Mousetrap.unbind('+');
Mousetrap.unbind('backspace');
Mousetrap.unbind('d');
};
}, [
addCutSegment, capture, changePlaybackRate, cutClick, playCommand, removeCutSegment,
setCutEnd, setCutStart, seekRel, shortStep, deleteSource,
]);
useEffect(() => {
document.ondragover = dragPreventer;
document.ondragend = dragPreventer;
electron.ipcRenderer.send('renderer-ready');
}, []);
useEffect(() => {
electron.ipcRenderer.send('setAskBeforeClose', askBeforeClose);
}, [askBeforeClose]);
const extractAllStreams = useCallback(async () => {
if (!filePath) return;
try {
setWorking(true);
await ffmpeg.extractStreams({ customOutDir, filePath, streams: mainStreams });
toast.fire({ icon: 'success', title: `All streams can be found as separate files at: ${outputDir}` });
} catch (err) {
errorToast('Failed to extract all streams');
console.error('Failed to extract all streams', err);
} finally {
setWorking(false);
}
}, [customOutDir, filePath, mainStreams, outputDir]);
function onExtractAllStreamsPress() {
extractAllStreams();
}
const addStreamSourceFile = useCallback(async (path) => {
if (externalStreamFiles[path]) return;
const { streams } = await ffmpeg.getAllStreams(path);
// console.log('streams', streams);
setExternalStreamFiles(old => ({ ...old, [path]: { streams } }));
setCopyStreamIdsForPath(path, () => fromPairs(streams.map(({ index }) => [index, true])));
}, [externalStreamFiles]);
const userOpenFiles = useCallback(async (filePaths) => {
if (filePaths.length < 1) return;
if (filePaths.length > 1) {
showMergeDialog(filePaths, mergeFiles);
return;
}
const firstFile = filePaths[0];
if (!filePath) {
load(firstFile);
return;
}
const { value } = await Swal.fire({
title: 'You opened a new file. What do you want to do?',
icon: 'question',
input: 'radio',
showCancelButton: true,
inputOptions: {
open: 'Open the file instead of the current one. You will lose all work',
add: 'Include all tracks from the new file',
},
inputValidator: (v) => !v && 'You need to choose something!',
});
if (value === 'open') {
load(firstFile);
} else if (value === 'add') {
addStreamSourceFile(firstFile);
setStreamsSelectorShown(true);
}
}, [addStreamSourceFile, filePath, load, mergeFiles]);
const onDrop = useCallback(async (ev) => {
ev.preventDefault();
const { files } = ev.dataTransfer;
const filePaths = Array.from(files).map(f => f.path);
if (filePaths.length === 1 && filePaths[0].toLowerCase().endsWith('.csv')) {
loadEdlFile(filePaths[0]);
return;
}
userOpenFiles(filePaths);
}, [userOpenFiles, loadEdlFile]);
useEffect(() => {
function fileOpened(event, filePaths) {
userOpenFiles(filePaths);
}
function closeFile() {
// eslint-disable-next-line no-alert
if (!window.confirm('Are you sure you want to close the current file? You will lose all work')) return;
resetState();
}
async function html5ify(event, speed) {
if (!filePath) return;
try {
setWorking(true);
if (['fast', 'slow', 'slow-audio'].includes(speed)) {
const html5FriendlyPathNew = getHtml5ifiedPath(filePath, speed);
const encodeVideo = ['slow', 'slow-audio'].includes(speed);
const encodeAudio = speed === 'slow-audio';
await ffmpeg.html5ify(filePath, html5FriendlyPathNew, encodeVideo, encodeAudio);
load(filePath, html5FriendlyPathNew);
} else {
await createDummyVideo(filePath);
}
} catch (err) {
errorToast('Failed to html5ify file');
console.error('Failed to html5ify file', err);
} finally {
setWorking(false);
}
}
function showOpenAndMergeDialog2() {
showOpenAndMergeDialog({
dialog,
defaultPath: outputDir,
onMergeClick: mergeFiles,
});
}
async function setStartOffset() {
const newStartTimeOffset = await promptTimeOffset(
startTimeOffset !== undefined ? formatDuration({ seconds: startTimeOffset }) : undefined,
);
if (newStartTimeOffset === undefined) return;
setStartTimeOffset(newStartTimeOffset);
}
function undo() {
cutSegmentsHistory.back();
}
function redo() {
cutSegmentsHistory.forward();
}
async function exportEdlFile() {
try {
const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.csv`, filters: [{ name: 'CSV files', extensions: ['csv'] }] });
if (canceled || !fp) return;
if (await exists(fp)) {
errorToast('File exists, bailing');
return;
}
await edlStore.save(fp, cutSegments);
} catch (err) {
errorToast('Failed to export CSV');
console.error('Failed to export CSV', err);
}
}
async function importEdlFile() {
if (!filePath) return;
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: 'CSV files', extensions: ['csv'] }] });
if (canceled || filePaths.length < 1) return;
await loadEdlFile(filePaths[0]);
}
electron.ipcRenderer.on('file-opened', fileOpened);
electron.ipcRenderer.on('close-file', closeFile);
electron.ipcRenderer.on('html5ify', html5ify);
electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog2);
electron.ipcRenderer.on('set-start-offset', setStartOffset);
electron.ipcRenderer.on('extract-all-streams', extractAllStreams);
electron.ipcRenderer.on('undo', undo);
electron.ipcRenderer.on('redo', redo);
electron.ipcRenderer.on('importEdlFile', importEdlFile);
electron.ipcRenderer.on('exportEdlFile', exportEdlFile);
return () => {
electron.ipcRenderer.removeListener('file-opened', fileOpened);
electron.ipcRenderer.removeListener('close-file', closeFile);
electron.ipcRenderer.removeListener('html5ify', html5ify);
electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2);
electron.ipcRenderer.removeListener('set-start-offset', setStartOffset);
electron.ipcRenderer.removeListener('extract-all-streams', extractAllStreams);
electron.ipcRenderer.removeListener('undo', undo);
electron.ipcRenderer.removeListener('importEdlFile', importEdlFile);
electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile);
};
}, [
load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath,
createDummyVideo, resetState, extractAllStreams, userOpenFiles, cutSegmentsHistory,
loadEdlFile, cutSegments, edlFilePath,
]);
async function showAddStreamSourceDialog() {
const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] });
if (canceled || filePaths.length < 1) return;
await addStreamSourceFile(filePaths[0]);
}
useEffect(() => {
document.body.addEventListener('drop', onDrop);
return () => document.body.removeEventListener('drop', onDrop);
}, [load, mergeFiles, onDrop]);
function renderCutTimeInput(type) {
const cutTimeManual = type === 'start' ? cutStartTimeManual : cutEndTimeManual;
const cutTimeInputStyle = { width: '8em', textAlign: type === 'start' ? 'right' : 'left' };
const isCutTimeManualSet = () => cutTimeManual !== undefined;
const set = type === 'start' ? setCutStartTimeManual : setCutEndTimeManual;
const handleCutTimeInput = (text) => {
// Allow the user to erase
if (text.length === 0) {
set();
return;
}
const time = parseDuration(text);
if (time === undefined) {
set(text);
return;
}
set();
const rel = time - startTimeOffset;
try {
setCutTime(type, rel);
} catch (err) {
console.error('Cannot set cut time', err);
}
seekAbs(rel);
};
const cutTime = type === 'start' ? currentApparentCutSeg.start : currentApparentCutSeg.end;
return (
<input
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
type="text"
onChange={e => handleCutTimeInput(e.target.value)}
value={isCutTimeManualSet()
? cutTimeManual
: formatDuration({ seconds: cutTime + startTimeOffset })}
/>
);
}
const commonFormats = ['mov', 'mp4', 'matroska', 'mp3', 'ipod'];
const commonFormatsMap = fromPairs(commonFormats.map(format => [format, allOutFormats[format]])
.filter(([f]) => f !== detectedFileFormat));
const otherFormatsMap = fromPairs(Object.entries(allOutFormats)
.filter(([f]) => ![...commonFormats, detectedFileFormat].includes(f)));
function getSegColors(seg) {
if (!seg) return {};
const { color } = seg;
return {
segBgColor: color.alpha(0.5).string(),
segActiveBgColor: color.lighten(0.5).alpha(0.5).string(),
segBorderColor: color.lighten(0.5).string(),
};
}
const {
segBgColor: currentSegBgColor,
segActiveBgColor: currentSegActiveBgColor,
segBorderColor: currentSegBorderColor,
} = getSegColors(currentCutSeg);
const jumpCutButtonStyle = {
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
};
function renderFormatOptions(map) {
return Object.entries(map).map(([f, name]) => (
<option key={f} value={f}>{f} - {name}</option>
));
}
function renderOutFmt({ width } = {}) {
return (
<select style={{ width }} defaultValue="" value={fileFormat} title="Output format" onChange={withBlur(e => setFileFormat(e.target.value))}>
<option key="disabled1" value="" disabled>Format</option>
{detectedFileFormat && (
<option key={detectedFileFormat} value={detectedFileFormat}>
{detectedFileFormat} - {allOutFormats[detectedFileFormat]} (detected)
</option>
)}
<option key="disabled2" value="" disabled>--- Common formats: ---</option>
{renderFormatOptions(commonFormatsMap)}
<option key="disabled3" value="" disabled>--- All formats: ---</option>
{renderFormatOptions(otherFormatsMap)}
</select>
);
}
function renderCaptureFormatButton() {
return (
<button
type="button"
title="Capture frame format"
onClick={withBlur(toggleCaptureFormat)}
>
{captureFormat}
</button>
);
}
const renderSettings = () => (
<Fragment>
<tr>
<td>Output format (default autodetected)</td>
<td style={{ width: '50%' }}>{renderOutFmt()}</td>
</tr>
<tr>
<td>
Working directory<br />
This is where working files, exported files, project files (CSV) are stored.
</td>
<td>
<button
type="button"
onClick={setOutputDir}
>
{customOutDir ? 'Custom working directory' : 'Same directory as input file'}
</button>
<div>{customOutDir}</div>
</td>
</tr>
<tr>
<td>Auto merge segments to one file after export?</td>
<td>
<button
type="button"
onClick={toggleAutoMerge}
>
{autoMerge ? 'Auto merge segments to one file' : 'Export separate files'}
</button>
</td>
</tr>
<tr>
<td>keyframe cut mode</td>
<td>
<button
type="button"
onClick={toggleKeyframeCut}
>
{keyframeCut ? 'Nearest keyframe cut - will cut at the nearest keyframe' : 'Normal cut - cut accurate position but could leave an empty portion'}
</button>
</td>
</tr>
<tr>
<td>
Discard (cut away) or keep selected segments from video when exporting
</td>
<td>
<button
type="button"
onClick={withBlur(() => setInvertCutSegments(v => !v))}
>
{invertCutSegments ? 'Discard' : 'Keep'}
</button>
</td>
</tr>
<tr>
<td>
Discard audio?
</td>
<td>
<button
type="button"
onClick={toggleStripAudio}
>
{copyAnyAudioTrack ? 'Keep audio tracks' : 'Discard all audio tracks'}
</button>
</td>
</tr>
<tr>
<td>
Extract unprocessable tracks to separate files?<br />
(data tracks such as GoPro GPS, telemetry etc. are not copied over by default because ffmpeg cannot cut them, thus they will cause the media duration to stay the same after cutting video/audio)
</td>
<td>
<button
type="button"
onClick={() => setAutoExportExtraStreams(v => !v)}
>
{autoExportExtraStreams ? 'Extract unprocessable tracks' : 'Discard all unprocessable tracks'}
</button>
</td>
</tr>
<tr>
<td>
Auto save project?<br />
The project will be stored along with the output files as a CSV file
</td>
<td>
<button
type="button"
onClick={() => setAutoSaveProjectFile(v => !v)}
>
{autoSaveProjectFile ? 'Auto save project' : 'Don\'t save project file'}
</button>
</td>
</tr>
<tr>
<td>
Snapshot capture format
</td>
<td>
{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>
<tr>
<td>Ask for confirmation when closing app?</td>
<td>
<button
type="button"
onClick={() => setAskBeforeClose(v => !v)}
>
{askBeforeClose ? 'Ask before closing' : 'Don\'t ask before closing'}
</button>
</td>
</tr>
</Fragment>
);
useEffect(() => {
loadMifiLink().then(setMifiLink);
}, []);
useEffect(() => {
// Testing:
if (isDev) load('/Users/mifi/Downloads/inp.MOV');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const topBarHeight = '2rem';
const bottomBarHeight = '6rem';
const VolumeIcon = muted || dummyVideoPath ? FaVolumeMute : FaVolumeUp;
const CutIcon = areWeCutting ? FiScissors : FaFileExport;
function renderInvertCutButton() {
return (
<div style={{ marginLeft: 5 }}>
<motion.div
animate={{ rotateX: invertCutSegments ? 0 : 180, width: 26, height: 26 }}
transition={{ duration: 0.3 }}
>
<FaYinYang
size={26}
role="button"
title={invertCutSegments ? 'Discard selected segments' : 'Keep selected segments'}
onClick={withBlur(() => setInvertCutSegments(v => !v))}
/>
</motion.div>
</div>
);
}
useEffect(() => {
const keyScrollPreventer = (e) => {
// https://stackoverflow.com/questions/8916620/disable-arrow-key-scrolling-in-users-browser
if (e.target === document.body && [32, 37, 38, 39, 40].indexOf(e.keyCode) > -1) {
e.preventDefault();
}
};
window.addEventListener('keydown', keyScrollPreventer);
return () => window.removeEventListener('keydown', keyScrollPreventer);
}, []);
async function onLabelSegmentPress() {
const { value } = await Swal.fire({
showCancelButton: true,
title: 'Label current segment',
inputValue: currentCutSeg.name,
input: 'text',
});
if (value != null) setCurrentSegmentName(value);
}
function renderSetCutpointButton(side) {
const start = side === 'start';
const Icon = start ? FaHandPointLeft : FaHandPointRight;
const border = `4px solid ${currentSegBorderColor}`;
return (
<Icon
size={13}
title="Set cut end to current position"
role="button"
style={{ padding: start ? '4px 4px 4px 2px' : '4px 2px 4px 4px', borderLeft: start && border, borderRight: !start && border, background: currentSegActiveBgColor, borderRadius: 6 }}
onClick={start ? setCutStart : setCutEnd}
/>
);
}
const primaryColor = 'hsl(194, 78%, 47%)';
const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit;
return (
<div>
<div style={{ background: '#6b6b6b', height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between' }}>
{filePath && (
<Fragment>
<SideSheet
containerProps={{ style: { maxWidth: '100%' } }}
position={Position.LEFT}
isShown={streamsSelectorShown}
onCloseComplete={() => setStreamsSelectorShown(false)}
>
<StreamsSelector
mainFilePath={filePath}
externalFiles={externalStreamFiles}
setExternalFiles={setExternalStreamFiles}
showAddStreamSourceDialog={showAddStreamSourceDialog}
streams={mainStreams}
isCopyingStreamId={isCopyingStreamId}
toggleCopyStreamId={toggleCopyStreamId}
setCopyStreamIdsForPath={setCopyStreamIdsForPath}
onExtractAllStreamsPress={onExtractAllStreamsPress}
/>
</SideSheet>
<Button height={20} iconBefore="list" onClick={withBlur(() => setStreamsSelectorShown(true))}>
Tracks ({numStreamsToCopy}/{numStreamsTotal})
</Button>
</Fragment>
)}
<div style={{ flexGrow: 1 }} />
{filePath && (
<Fragment>
<button
type="button"
onClick={withBlur(setOutputDir)}
title={customOutDir}
>
{`Working dir ${customOutDir ? 'set' : 'unset'}`}
</button>
{renderOutFmt({ width: 60 })}
<button
style={{ opacity: cutSegments.length < 2 ? 0.4 : undefined }}
type="button"
title={autoMerge ? 'Auto merge segments to one file after export' : 'Export to separate files'}
onClick={withBlur(toggleAutoMerge)}
>
<AutoMergeIcon /> {autoMerge ? 'Merge cuts' : 'Separate files'}
</button>
<button
type="button"
title={`Cut mode ${keyframeCut ? 'nearest keyframe cut' : 'normal cut'}`}
onClick={withBlur(toggleKeyframeCut)}
>
{keyframeCut ? 'Keyframe cut' : 'Normal cut'}
</button>
<button
type="button"
title={`Discard audio? Current: ${copyAnyAudioTrack ? 'keep audio tracks' : 'Discard audio tracks'}`}
onClick={withBlur(toggleStripAudio)}
>
{copyAnyAudioTrack ? 'Keep audio' : 'Discard audio'}
</button>
</Fragment>
)}
<IoIosHelpCircle size={24} role="button" onClick={toggleHelp} style={{ verticalAlign: 'middle', marginLeft: 5 }} />
</div>
{!filePath && (
<div style={{ position: 'fixed', left: 0, right: 0, top: topBarHeight, bottom: bottomBarHeight, border: '2vmin dashed #252525', color: '#505050', margin: '5vmin', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', whiteSpace: 'nowrap' }}>
<div style={{ fontSize: '9vmin' }}>DROP VIDEO(S)</div>
{mifiLink && mifiLink.loadUrl && (
<div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}>
<iframe src={mifiLink.loadUrl} title="iframe" style={{ background: 'rgba(0,0,0,0)', border: 'none', pointerEvents: 'none', width: '100%', height: '100%', position: 'absolute' }} />
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(mifiLink.targetUrl)} />
</div>
)}
</div>
)}
<AnimatePresence>
{working && (
<div style={{
position: 'absolute', zIndex: 1, bottom: bottomBarHeight, top: topBarHeight, left: 0, right: 0, display: 'flex', justifyContent: 'center', alignItems: 'center', pointerEvents: 'none',
}}
>
<motion.div
style={{ background: primaryColor, boxShadow: `${primaryColor} 0px 0px 20px 25px`, borderRadius: 20, paddingBottom: 15, color: 'white', textAlign: 'center', fontSize: 14 }}
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }}
>
<div style={{ width: 150, height: 150 }}>
<Lottie
options={{ loop: true, autoplay: true, animationData: loadingLottie }}
style={{ width: '170%', height: '130%', marginLeft: '-35%', marginTop: '-29%' }}
/>
</div>
<div style={{ marginTop: 10 }}>
WORKING
</div>
{(cutProgress != null) && (
<div style={{ marginTop: 10 }}>
{`${Math.floor(cutProgress * 100)} %`}
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
<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}
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
src={fileUri}
onPlay={() => onPlayingChange(true)}
onPause={() => onPlayingChange(false)}
onDurationChange={e => setDuration(e.target.duration)}
onTimeUpdate={onTimeUpdate}
/>
{framePath && frameRenderEnabled && (
<img
style={{
width: '100%', height: '100%', objectFit: 'contain', left: 0, right: 0, top: 0, bottom: 0, position: 'absolute', background: 'black',
}}
src={framePath}
alt=""
/>
)}
</div>
{rotationPreviewRequested && (
<div style={{
position: 'absolute', top: topBarHeight, marginTop: '1em', marginRight: '1em', right: 0, color: 'white',
}}
>
Lossless rotation preview
</div>
)}
{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>
)}
<div className="controls-wrapper" style={{ height: bottomBarHeight }}>
<Hammer
onTap={handleTap}
onPan={handleTap}
options={{ recognizers: {} }}
>
<div style={{ position: 'relative' }}>
<div
style={{ overflowX: 'scroll' }}
id="timeline-scroller"
onWheel={onWheel}
onScroll={onTimelineScroll}
ref={timelineScrollerRef}
>
<div
style={{ height: 36, width: `${zoom * 100}%`, position: 'relative', backgroundColor: '#444' }}
ref={timelineWrapperRef}
>
{currentTimePos !== undefined && <motion.div transition={{ type: 'spring', damping: 70, stiffness: 800 }} animate={{ left: currentTimePos }} style={{ position: 'absolute', bottom: 0, top: 0, zIndex: 3, backgroundColor: 'black', width: currentTimeWidth, pointerEvents: 'none' }} />}
{commandedTimePos !== undefined && <div style={{ left: commandedTimePos, position: 'absolute', bottom: 0, top: 0, zIndex: 4, backgroundColor: 'white', width: currentTimeWidth, pointerEvents: 'none' }} />}
{apparentCutSegments.map((seg, i) => {
const {
segBgColor, segActiveBgColor, segBorderColor,
} = getSegColors(seg);
return (
<TimelineSeg
key={seg.uuid}
segNum={i}
segBgColor={segBgColor}
segActiveBgColor={segActiveBgColor}
segBorderColor={segBorderColor}
onSegClick={currentSegIndexNew => setCurrentSegIndex(currentSegIndexNew)}
isActive={i === currentSegIndexSafe}
duration={durationSafe}
name={seg.name}
cutStart={seg.start}
cutEnd={seg.end}
invertCutSegments={invertCutSegments}
zoomed={zoomed}
/>
);
})}
{inverseCutSegments && inverseCutSegments.map((seg, i) => (
<InverseCutSegment
// eslint-disable-next-line react/no-array-index-key
key={i}
seg={seg}
duration={durationSafe}
invertCutSegments={invertCutSegments}
/>
))}
</div>
</div>
<div style={{ position: 'absolute', left: 0, right: 0, top: 0, bottom: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' }}>
<div style={{ background: 'rgba(0,0,0,0.4)', borderRadius: 3, padding: '2px 4px', color: 'rgba(255, 255, 255, 0.8)' }}>
{formatTimecode(offsetCurrentTime)}
</div>
</div>
</div>
</Hammer>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<i
className="button fa fa-step-backward"
role="button"
tabIndex="0"
title="Jump to start of video"
onClick={() => seekAbs(0)}
/>
{renderSetCutpointButton('start')}
<div style={{ position: 'relative' }}>
{renderCutTimeInput('start')}
<i
style={{ ...jumpCutButtonStyle, left: 0 }}
className="fa fa-step-backward"
title="Jump to cut start"
role="button"
tabIndex="0"
onClick={withBlur(jumpCutStart)}
/>
</div>
<i
className="button fa fa-caret-left"
role="button"
tabIndex="0"
onClick={() => shortStep(-1)}
/>
<i
className={`button fa ${playing ? 'fa-pause' : 'fa-play'}`}
role="button"
tabIndex="0"
onClick={playCommand}
/>
<i
className="button fa fa-caret-right"
role="button"
tabIndex="0"
onClick={() => shortStep(1)}
/>
<div style={{ position: 'relative' }}>
{renderCutTimeInput('end')}
<i
style={{ ...jumpCutButtonStyle, right: 0 }}
className="fa fa-step-forward"
title="Jump to cut end"
role="button"
tabIndex="0"
onClick={withBlur(jumpCutEnd)}
/>
</div>
{renderSetCutpointButton('end')}
<i
className="button fa fa-step-forward"
role="button"
tabIndex="0"
title="Jump to end of video"
onClick={() => seekAbs(durationSafe)}
/>
</div>
</div>
<div className="left-menu" style={{ position: 'absolute', left: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
<FaPlus
size={30}
style={{ margin: '0 5px', color: 'white' }}
role="button"
title="Add segment"
onClick={addCutSegment}
/>
<FaMinus
size={30}
style={{ margin: '0 5px', background: cutSegments.length < 2 ? undefined : currentSegBgColor, borderRadius: 3, color: 'white' }}
role="button"
title={`Delete current segment ${currentSegIndexSafe + 1}`}
onClick={removeCutSegment}
/>
{renderInvertCutButton()}
<select 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) => {
const val = 2 ** z;
return (
<option key={val} value={String(val)}>Zoom {val}x</option>
);
})}
</select>
<FaTag
size={10}
title="Label segment"
role="button"
style={{ padding: 4, border: `2px solid ${currentSegBorderColor}`, background: currentSegActiveBgColor, borderRadius: 6 }}
onClick={onLabelSegmentPress}
/>
</div>
<div className="right-menu" style={{ position: 'absolute', right: 0, bottom: 0, padding: '.3em', display: 'flex', alignItems: 'center' }}>
<div>
<span style={{ width: 40, textAlign: 'right', display: 'inline-block' }}>{isRotationSet && rotationStr}</span>
<MdRotate90DegreesCcw
size={26}
style={{ margin: '0 5px', verticalAlign: 'middle' }}
title={`Set output rotation. Current: ${isRotationSet ? rotationStr : 'Don\'t modify'}`}
onClick={increaseRotation}
role="button"
/>
</div>
<FaTrashAlt
title="Delete source file"
style={{ padding: '5px 10px' }}
size={16}
onClick={deleteSource}
role="button"
/>
{renderCaptureFormatButton()}
<IoIosCamera
style={{ paddingLeft: 5, paddingRight: 15 }}
size={25}
title="Capture frame"
onClick={capture}
/>
<span
style={{ background: primaryColor, borderRadius: 5, padding: '3px 7px', fontSize: 14 }}
onClick={cutClick}
title={cutSegments.length > 1 ? 'Export all segments' : 'Export selection'}
role="button"
>
<CutIcon
style={{ verticalAlign: 'middle', marginRight: 3 }}
size={16}
/>
Export
</span>
</div>
<HelpSheet
visible={!!helpVisible}
onTogglePress={toggleHelp}
renderSettings={renderSettings}
ffmpegCommandLog={ffmpegCommandLog}
sortedCutSegments={sortedCutSegments}
formatTimecode={formatTimecode}
/>
</div>
);
});
ReactDOM.render(<App />, document.getElementById('app'));
console.log('Version', electron.remote.app.getVersion());