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

1199 lines
38 KiB
React

import React, { memo, useEffect, useState, useCallback, useRef } from 'react';
import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io';
import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp } from 'react-icons/fa';
import { MdRotate90DegreesCcw } from 'react-icons/md';
import { FiScissors } from 'react-icons/fi';
import { AnimatePresence } from 'framer-motion';
import { Popover, Button } from 'evergreen-ui';
import fromPairs from 'lodash/fromPairs';
import clamp from 'lodash/clamp';
import clone from 'lodash/clone';
import HelpSheet from './HelpSheet';
import TimelineSeg from './TimelineSeg';
import StreamsSelector from './StreamsSelector';
import { loadMifiLink } from './mifi';
const isDev = require('electron-is-dev');
10 years ago
const electron = require('electron'); // eslint-disable-line
const Mousetrap = require('mousetrap');
const Hammer = require('react-hammerjs').default;
const path = require('path');
const trash = require('trash');
const uuid = require('uuid');
10 years ago
const ReactDOM = require('react-dom');
const { default: PQueue } = require('p-queue');
const { unlink, exists } = require('fs-extra');
10 years ago
const { showMergeDialog, showOpenAndMergeDialog } = require('./merge/merge');
const captureFrame = require('./capture-frame');
const ffmpeg = require('./ffmpeg');
const { defaultProcessedCodecTypes, getStreamFps } = ffmpeg;
const {
getOutPath, parseDuration, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle,
6 years ago
promptTimeOffset, generateColor, getOutDir,
} = require('./util');
10 years ago
const { dialog } = electron.remote;
function withBlur(cb) {
return (e) => {
cb(e);
e.target.blur();
};
}
function createSegment({ start, end } = {}) {
return {
start,
end,
color: generateColor(),
uuid: uuid.v4(),
};
}
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(() => {
const [framePath, setFramePath] = useState();
const [html5FriendlyPath, setHtml5FriendlyPath] = useState();
const [working, setWorking] = useState(false);
const [dummyVideoPath, setDummyVideoPath] = useState(false);
const [playing, setPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState();
const [duration, setDuration] = useState();
const [cutSegments, setCutSegments] = useState([createSegment()]);
const [currentSeg, setCurrentSeg] = useState(0);
const [cutStartTimeManual, setCutStartTimeManual] = useState();
const [cutEndTimeManual, setCutEndTimeManual] = 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 [detectedFps, setDetectedFps] = useState();
const [streams, setStreams] = useState([]);
const [copyStreamIds, setCopyStreamIds] = useState({});
const [muted, setMuted] = useState(false);
// Global state
const [captureFormat, setCaptureFormat] = useState('jpeg');
const [customOutDir, setCustomOutDir] = useState();
const [keyframeCut, setKeyframeCut] = useState(true);
const [autoMerge, setAutoMerge] = useState(false);
const [helpVisible, setHelpVisible] = useState(false);
const [timecodeShowFrames, setTimecodeShowFrames] = useState(false);
const [mifiLink, setMifiLink] = useState();
const videoRef = useRef();
const timelineWrapperRef = useRef();
function toggleCopyStreamId(index) {
setCopyStreamIds(v => ({ ...v, [index]: !v[index] }));
}
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;
}
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;
video.currentTime = 0;
video.playbackRate = 1;
10 years ago
setFileNameTitle();
setFramePath();
setHtml5FriendlyPath();
setDummyVideoPath();
setWorking(false);
setPlaying(false);
setDuration();
setCurrentSeg(0);
setCutSegments([createSegment()]);
setCutStartTimeManual();
setCutEndTimeManual();
setFileFormat();
setDetectedFileFormat();
setRotation(360);
setCutProgress();
setStartTimeOffset(0);
setRotationPreviewRequested(false);
setFilePath(''); // Setting video src="" prevents memory leak in chromium
setDetectedFps();
setStreams([]);
setCopyStreamIds({});
setMuted(false);
}, []);
useEffect(() => () => {
if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error);
}, [dummyVideoPath]);
const frameRenderEnabled = !!(rotationPreviewRequested || dummyVideoPath);
const setCutTime = useCallback((type, time) => {
const cloned = clone(cutSegments);
cloned[currentSeg][type] = time;
setCutSegments(cloned);
}, [currentSeg, cutSegments]);
function formatTimecode(sec) {
return formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined });
}
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();
if (cutStartTime === undefined && cutEndTime === undefined) return;
const suggestedStart = currentTime;
const suggestedEnd = suggestedStart + 10;
const cutSegmentsNew = [
...cutSegments,
createSegment({
start: currentTime,
end: suggestedEnd <= duration ? suggestedEnd : undefined,
}),
];
const currentSegNew = cutSegmentsNew.length - 1;
setCutSegments(cutSegmentsNew);
setCurrentSeg(currentSegNew);
}, [
getCutEndTime, getCutStartTime, cutSegments, currentTime, duration,
]);
const setCutStart = useCallback(() => {
const curSeg = getCutSeg();
// 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 currentTime
if (curSeg.start != null && curSeg.end != null && currentTime > curSeg.end) {
addCutSegment();
} else {
setCutTime('start', currentTime);
}
}, [setCutTime, currentTime, getCutSeg, addCutSegment]);
const setCutEnd = useCallback(() => setCutTime('end', currentTime), [setCutTime, currentTime]);
async function setOutputDir() {
const { filePaths } = await dialog.showOpenDialog({ properties: ['openDirectory'] });
setCustomOutDir((filePaths && filePaths.length === 1) ? filePaths[0] : undefined);
}
6 years ago
const fileUri = (dummyVideoPath || html5FriendlyPath || filePath || '').replace(/#/g, '%23');
function getOutputDir() {
if (customOutDir) return customOutDir;
if (filePath) return path.dirname(filePath);
return undefined;
}
const outputDir = getOutputDir();
// 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 (currentTime == null || !filePath) return;
try {
const framePathNew = await ffmpeg.renderFrame(currentTime, filePath, effectiveRotation);
setFramePath(framePathNew);
} catch (err) {
console.error(err);
}
});
}
10 years ago
await queue.onIdle();
}
throttledRender();
}, [
filePath, currentTime, frameRenderEnabled, effectiveRotation,
]);
// Cleanup old frames
useEffect(() => () => URL.revokeObjectURL(framePath), [framePath]);
function onPlayingChange(val) {
setPlaying(val);
if (!val) videoRef.current.playbackRate = 1;
}
function onTimeUpdate(e) {
const { currentTime: ct } = e.target;
if (currentTime === ct) return;
setRotationPreviewRequested(false); // Reset this
setCurrentTime(ct);
}
function increaseRotation() {
setRotation((r) => (r + 90) % 450);
setRotationPreviewRequested(true);
}
const getApparentCutStartTime = useCallback((i) => {
const cutStartTime = getCutStartTime(i);
if (cutStartTime !== undefined) return cutStartTime;
return 0;
}, [getCutStartTime]);
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 offsetCurrentTime = (currentTime || 0) + startTimeOffset;
const mergeFiles = useCallback(async (paths) => {
try {
setWorking(true);
// console.log('merge', paths);
await ffmpeg.mergeAnyFiles({
customOutDir, paths,
});
} 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 copyAnyAudioTrack = streams.some(stream => copyStreamIds[stream.index] && stream.codec_type === 'audio');
function toggleStripAudio() {
setCopyStreamIds((old) => {
const newCopyStreamIds = { ...old };
streams.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(currentSeg, 1);
const currentSegNew = Math.min(currentSeg, cutSegmentsNew.length - 1);
setCurrentSeg(currentSegNew);
setCutSegments(cutSegmentsNew);
}, [currentSeg, cutSegments]);
const jumpCutStart = () => seekAbs(getApparentCutStartTime());
const jumpCutEnd = () => seekAbs(getApparentCutEndTime());
function handleTap(e) {
const target = timelineWrapperRef.current;
const rect = target.getBoundingClientRect();
const relX = e.srcEvent.pageX - (rect.left + document.body.scrollLeft);
seekAbs((relX / target.offsetWidth) * (duration || 0));
}
const playCommand = useCallback(() => {
const video = videoRef.current;
if (playing) return video.pause();
return video.play().catch((err) => {
console.error(err);
if (err.name === 'NotSupportedError') {
toast.fire({ type: 'error', title: 'This format/codec is not supported. Try to convert it to a friendly format/codec in the player from the "File" menu.', timer: 10000 });
}
});
}, [playing]);
10 years ago
async function deleteSourceClick() {
// eslint-disable-next-line no-alert
if (working || !window.confirm('Are you sure you want to move the source file to trash?')) return;
try {
setWorking(true);
await trash(filePath);
if (html5FriendlyPath) await trash(html5FriendlyPath);
} catch (err) {
toast.fire({ type: 'error', title: `Failed to trash source file: ${err.message}` });
} finally {
resetState();
}
}
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)) {
errorToast('Start time must be before end time');
return;
10 years ago
}
try {
setWorking(true);
const segments = cutSegments.map((seg, i) => ({
cutFrom: getApparentCutStartTime(i),
cutTo: getCutEndTime(i),
cutToApparent: getApparentCutEndTime(i),
}));
const outFiles = await ffmpeg.cutMultiple({
customOutDir,
filePath,
format: fileFormat,
videoDuration: duration,
rotation: effectiveRotation,
copyStreamIds,
keyframeCut,
segments,
onProgress: setCutProgress,
});
if (outFiles.length > 1 && autoMerge) {
setCutProgress(0); // TODO implement progress
await ffmpeg.autoMergeSegments({
customOutDir,
sourceFile: filePath,
segmentPaths: outFiles,
});
}
toast.fire({ timer: 10000, type: 'success', title: `Cut completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` });
} catch (err) {
9 years ago
console.error('stdout:', err.stdout);
console.error('stderr:', err.stderr);
9 years ago
if (err.code === 1 || err.code === 'ENOENT') {
errorToast(`Whoops! ffmpeg was unable to cut this video. Try each the following things before attempting to cut again:\n1. Select a different output format from the ${fileFormat} button (matroska takes almost everything).\n2. toggle the button "all" to "ps"`);
return;
}
showFfmpegFail(err);
} finally {
setWorking(false);
9 years ago
}
}, [
effectiveRotation, getApparentCutStartTime, getApparentCutEndTime, getCutEndTime,
getCutStartTime, isCutRangeValid, working, cutSegments, duration, filePath, keyframeCut,
autoMerge, customOutDir, fileFormat, copyStreamIds,
]);
function showUnsupportedFileMessage() {
toast.fire({ timer: 10000, type: '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 cut operation will however be lossless and contains audio!' });
}
// TODO use ffmpeg to capture frame
const capture = useCallback(async () => {
if (!filePath) return;
6 years ago
if (html5FriendlyPath || dummyVideoPath) {
errorToast('Capture frame from this video not yet implemented');
return;
}
try {
await captureFrame(customOutDir, filePath, videoRef.current, currentTime, captureFormat);
} catch (err) {
console.error(err);
errorToast('Failed to capture frame');
}
6 years ago
}, [filePath, currentTime, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath]);
10 years ago
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 createDummyVideo = useCallback(async (fp) => {
const html5ifiedDummyPathDummy = getOutPath(customOutDir, fp, 'html5ified-dummy.mkv');
await ffmpeg.html5ifyDummy(fp, html5ifiedDummyPathDummy);
setDummyVideoPath(html5ifiedDummyPathDummy);
setHtml5FriendlyPath();
showUnsupportedFileMessage();
}, [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 load = useCallback(async (fp, html5FriendlyPathRequested) => {
console.log('Load', { fp, html5FriendlyPathRequested });
if (working) {
errorToast('I\'m busy');
return;
}
resetState();
setWorking(true);
try {
const ff = await ffmpeg.getFormat(fp);
if (!ff) {
errorToast('Unsupported file');
return;
}
const { streams: streamsNew } = await ffmpeg.getAllStreams(fp);
console.log('streams', streamsNew);
setStreams(streamsNew);
setCopyStreamIds(fromPairs(streamsNew.map((stream) => [
stream.index, defaultProcessedCodecTypes.includes(stream.codec_type),
])));
streamsNew.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(streamsNew)
) {
await createDummyVideo(fp);
}
} catch (err) {
if (err.code === 1 || err.code === 'ENOENT') {
errorToast('Unsupported file');
return;
}
showFfmpegFail(err);
} finally {
setWorking(false);
}
}, [resetState, working, createDummyVideo, checkExistingHtml5FriendlyFile]);
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());
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');
};
}, [
addCutSegment, capture, changePlaybackRate, cutClick, playCommand, removeCutSegment,
setCutEnd, setCutStart, seekRel, shortStep,
]);
useEffect(() => {
document.ondragover = dragPreventer;
document.ondragend = dragPreventer;
electron.ipcRenderer.send('renderer-ready');
}, []);
const extractAllStreams = useCallback(async () => {
if (!filePath) return;
try {
setWorking(true);
await ffmpeg.extractAllStreams({ customOutDir, filePath });
toast.fire({ type: 'success', title: `All streams can be found as separate files at: ${getOutDir(customOutDir, filePath)}` });
} catch (err) {
errorToast('Failed to extract all streams');
console.error('Failed to extract all streams', err);
} finally {
setWorking(false);
}
}, [customOutDir, filePath]);
function onExtractAllStreamsPress() {
extractAllStreams();
}
useEffect(() => {
function fileOpened(event, filePaths) {
if (!filePaths || filePaths.length !== 1) return;
load(filePaths[0]);
}
6 years ago
function closeFile() {
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);
}
electron.ipcRenderer.on('file-opened', fileOpened);
6 years ago
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);
return () => {
electron.ipcRenderer.removeListener('file-opened', fileOpened);
6 years ago
electron.ipcRenderer.removeListener('close-file', fileOpened);
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);
};
}, [
load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath,
createDummyVideo, resetState, extractAllStreams,
]);
const onDrop = useCallback((ev) => {
ev.preventDefault();
const { files } = ev.dataTransfer;
if (files.length < 1) return;
if (files.length === 1) load(files[0].path);
else showMergeDialog(Array.from(files).map(f => f.path), mergeFiles);
}, [load, mergeFiles]);
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;
setCutTime(type, rel);
seekAbs(rel);
};
const cutTime = type === 'start' ? getApparentCutStartTime() : getApparentCutEndTime();
return (
<input
style={{ ...cutTimeInputStyle, color: isCutTimeManualSet() ? '#dc1d1d' : undefined }}
type="text"
onChange={e => handleCutTimeInput(e.target.value)}
value={isCutTimeManualSet()
? cutTimeManual
: formatDuration({ seconds: cutTime + startTimeOffset })}
/>
);
}
const selectableFormats = ['mov', 'mp4', 'matroska'].filter(f => f !== detectedFileFormat);
const durationSafe = duration || 1;
const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`;
const segColor = getCutSeg().color;
const segBgColor = segColor.alpha(0.5).string();
const jumpCutButtonStyle = {
position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px',
};
function renderOutFmt({ width } = {}) {
return (
<select style={{ width }} defaultValue="" value={fileFormat} title="Format of current file" onChange={withBlur(e => setFileFormat(e.target.value))}>
<option key="" value="" disabled>Out fmt</option>
{detectedFileFormat && (
<option key={detectedFileFormat} value={detectedFileFormat}>
{detectedFileFormat}
</option>
)}
{selectableFormats.map(f => <option key={f} value={f}>{f}</option>)}
</select>
);
}
function renderCaptureFormatButton() {
return (
<button
type="button"
title="Capture frame format"
onClick={withBlur(toggleCaptureFormat)}
>
{captureFormat}
</button>
);
}
function renderSettings() {
return (
<table>
<tbody>
<tr>
<td>Output format (default autodetected)</td>
<td style={{ width: '50%' }}>{renderOutFmt()}</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>Auto merge segments to one file after export?</td>
<td>
<button
type="button"
onClick={toggleAutoMerge}
>
{autoMerge ? 'Auto merge segments to one file' : 'Export separate segments'}
</button>
</td>
</tr>
<tr>
<td>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>
Delete audio?
</td>
<td>
<button
type="button"
onClick={toggleStripAudio}
>
{!copyAnyAudioTrack ? 'Delete all audio tracks' : 'Keep audio tracks'}
</button>
</td>
</tr>
<tr>
<td>Output directory</td>
<td>
<button
type="button"
onClick={setOutputDir}
>
{customOutDir ? 'Custom output directory' : 'Output files to same directory as current file'}
</button>
<div>{customOutDir}</div>
</td>
</tr>
<tr>
<td>
Snapshot capture format
</td>
<td>
{renderCaptureFormatButton()}
</td>
</tr>
</tbody>
</table>
);
}
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 ? FaVolumeMute : FaVolumeUp;
return (
<div>
<div style={{ background: '#6b6b6b', height: topBarHeight, display: 'flex', alignItems: 'center', padding: '0 5px', justifyContent: 'space-between' }}>
<Popover
content={(
<StreamsSelector
streams={streams}
copyStreamIds={copyStreamIds}
toggleCopyStreamId={toggleCopyStreamId}
onExtractAllStreamsPress={onExtractAllStreamsPress}
/>
)}
>
<Button height={20} iconBefore="list">Tracks</Button>
</Popover>
<div style={{ flexGrow: 1 }} />
{renderOutFmt({ width: 60 })}
<button
style={{ opacity: cutSegments.length < 2 ? 0.4 : undefined }}
type="button"
title={`Auto merge segments to one file after export? ${autoMerge ? 'Auto merge enabled' : 'No merging'}`}
onClick={withBlur(toggleAutoMerge)}
>
{autoMerge ? 'Merge cuts' : 'Separate cuts'}
</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={`Delete audio? Current: ${copyAnyAudioTrack ? 'keep audio tracks' : 'delete audio tracks'}`}
onClick={withBlur(toggleStripAudio)}
>
{copyAnyAudioTrack ? 'Keep audio' : 'Delete audio'}
</button>
<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</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>
)}
{working && (
<div style={{
color: 'white', background: 'rgba(0, 0, 0, 0.3)', borderRadius: '.5em', margin: '1em', padding: '.2em .5em', position: 'absolute', zIndex: 1, top: topBarHeight, left: 0,
}}
>
<i className="fa fa-cog fa-spin fa-3x fa-fw" style={{ verticalAlign: 'middle', width: '1em', height: '1em' }} />
{cutProgress != null && (
<span style={{ color: 'rgba(255, 255, 255, 0.7)', paddingLeft: '.4em' }}>
{`${Math.floor(cutProgress * 100)} %`}
</span>
)}
</div>
)}
{/* eslint-disable jsx-a11y/media-has-caption */}
<div style={{ position: 'absolute', top: topBarHeight, left: 0, right: 0, bottom: bottomBarHeight }}>
<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>
{/* eslint-enable jsx-a11y/media-has-caption */}
{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={() => setMuted(v => !v)}
/>
</div>
)}
<div className="controls-wrapper" style={{ height: bottomBarHeight }}>
<Hammer
onTap={handleTap}
onPan={handleTap}
options={{ recognizers: {} }}
>
<div>
<div className="timeline-wrapper" ref={timelineWrapperRef}>
6 years ago
{currentTimePos !== undefined && <div style={{ position: 'absolute', bottom: 0, top: 0, left: currentTimePos, zIndex: 3, backgroundColor: 'rgba(255, 255, 255, 1)', width: 1, pointerEvents: 'none' }} />}
<AnimatePresence>
{cutSegments.map((seg, i) => (
<TimelineSeg
key={seg.uuid}
segNum={i}
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)}
/>
))}
</AnimatePresence>
<div id="current-time-display">
<span role="button" onClick={() => setTimecodeShowFrames(v => !v)}>
{formatTimecode(offsetCurrentTime)}
</span>
</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)}
/>
<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>
10 years ago
<i
className="button fa fa-step-forward"
role="button"
tabIndex="0"
title="Jump to end of video"
onClick={() => seekAbs(durationSafe)}
/>
</div>
<div>
<FaAngleLeft
title="Set cut start to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }}
size={16}
onClick={setCutStart}
role="button"
/>
<FaTrashAlt
title="Delete source file"
style={{ padding: 5 }}
size={16}
onClick={deleteSourceClick}
role="button"
/>
<FaAngleRight
title="Set cut end to current position"
style={{ background: segBgColor, borderRadius: 10, padding: 5 }}
size={16}
onClick={setCutEnd}
role="button"
/>
</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 cut segment"
onClick={addCutSegment}
/>
<FaMinus
size={30}
style={{ margin: '0 5px', background: cutSegments.length < 2 ? undefined : segBgColor, borderRadius: 3, color: 'white' }}
role="button"
title={`Delete current segment ${currentSeg + 1}`}
onClick={removeCutSegment}
/>
10 years ago
</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>
{renderCaptureFormatButton()}
<IoIosCamera
style={{ paddingLeft: 5, paddingRight: 15 }}
size={25}
title="Capture frame"
onClick={capture}
/>
<span style={{ background: 'hsl(194, 78%, 47%)', borderRadius: 5, padding: '3px 7px', fontSize: 14 }}>
<FiScissors
style={{ verticalAlign: 'middle', marginRight: 3 }}
size={16}
onClick={cutClick}
title={cutSegments.length > 1 ? 'Export all segments' : 'Export selection'}
/>
Export
</span>
</div>
<HelpSheet
visible={!!helpVisible}
onTogglePress={toggleHelp}
renderSettings={renderSettings}
/>
</div>
);
});
10 years ago
ReactDOM.render(<App />, document.getElementById('app'));
console.log('Version', electron.remote.app.getVersion());