import React, { memo, useEffect, useState, useCallback, useRef } from 'react'; import { IoIosHelpCircle, IoIosCamera } from 'react-icons/io'; import { FaPlus, FaMinus, FaAngleLeft, FaAngleRight, FaTrashAlt, FaVolumeMute, FaVolumeUp, FaYinYang, FaFileExport } from 'react-icons/fa'; import { MdRotate90DegreesCcw } from 'react-icons/md'; import { GiYinYang } from 'react-icons/gi'; 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 fromPairs from 'lodash/fromPairs'; import clamp from 'lodash/clamp'; import clone from 'lodash/clone'; import sortBy from 'lodash/sortBy'; import flatMap from 'lodash/flatMap'; 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 { dirname } = require('path'); 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 { 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 } = {}) { 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 [currentSegIndex, setCurrentSegIndex] = 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 [externalStreamFiles, setExternalStreamFiles] = useState([]); const [detectedFps, setDetectedFps] = useState(); const [mainStreams, setStreams] = useState([]); const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({}); const [muted, setMuted] = useState(false); const [streamsSelectorShown, setStreamsSelectorShown] = 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 [invertCutSegments, setInvertCutSegments] = useState(false); const videoRef = useRef(); const timelineWrapperRef = useRef(); 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; } 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; setFileNameTitle(); setFramePath(); setHtml5FriendlyPath(); setDummyVideoPath(); setWorking(false); setPlaying(false); setDuration(); setCurrentSegIndex(0); setCutSegments([createSegment()]); setCutStartTimeManual(); setCutEndTimeManual(); setFileFormat(); setDetectedFileFormat(); setRotation(360); setCutProgress(); setStartTimeOffset(0); setRotationPreviewRequested(false); setFilePath(''); // Setting video src="" prevents memory leak in chromium setExternalStreamFiles([]); setDetectedFps(); setStreams([]); setCopyStreamIdsByFile({}); setMuted(false); setInvertCutSegments(false); setStreamsSelectorShown(false); }, []); 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(time) { return time !== undefined ? time : 0; } const getSegApparentEnd = useCallback((time) => { if (time !== undefined) return time; if (duration !== undefined) return duration; return 0; // Haven't gotten duration yet }, [duration]); const apparentCutSegments = cutSegments.map(cutSegment => ({ ...cutSegment, start: getSegApparentStart(cutSegment.start), end: getSegApparentEnd(cutSegment.end), })); const invalidSegUuids = apparentCutSegments .filter(cutSegment => cutSegment.start >= cutSegment.end) .map(cutSegment => cutSegment.uuid); const haveInvalidSegs = invalidSegUuids.length > 0; const currentCutSeg = cutSegments[currentSegIndex]; const currentApparentCutSeg = apparentCutSegments[currentSegIndex]; const areWeCutting = apparentCutSegments.length > 1 || isCuttingStart(currentApparentCutSeg.start) || isCuttingEnd(currentApparentCutSeg.end, duration); const inverseCutSegments = (() => { if (haveInvalidSegs) return undefined; if (apparentCutSegments.length < 1) return undefined; const sorted = sortBy(apparentCutSegments, 'start'); const foundOverlap = sorted.some((cutSegment, i) => { if (i === 0) return false; return sorted[i - 1].end > cutSegment.start; }); if (foundOverlap) return undefined; if (duration == null) return undefined; const ret = []; if (sorted[0].start > 0) { ret.push({ start: 0, end: sorted[0].start, }); } sorted.forEach((cutSegment, i) => { if (i === 0) return; ret.push({ start: sorted[i - 1].end, end: cutSegment.start, }); }); const last = sorted[sorted.length - 1]; if (last.end < duration) { ret.push({ start: last.end, end: duration, }); } return ret; })(); const setCutTime = useCallback((type, time) => { const cloned = clone(cutSegments); cloned[currentSegIndex][type] = time; setCutSegments(cloned); }, [currentSegIndex, cutSegments]); function formatTimecode(sec) { return formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined }); } const addCutSegment = useCallback(() => { const cutStartTime = currentCutSeg.start; const cutEndTime = currentCutSeg.end; 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 currentSegIndexNew = cutSegmentsNew.length - 1; setCutSegments(cutSegmentsNew); setCurrentSegIndex(currentSegIndexNew); }, [ currentCutSeg, cutSegments, currentTime, duration, ]); 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 currentTime if (currentCutSeg.start != null && currentCutSeg.end != null && currentTime > currentCutSeg.end) { addCutSegment(); } else { setCutTime('start', currentTime); } }, [setCutTime, currentTime, currentCutSeg, 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); } const fileUri = (dummyVideoPath || html5FriendlyPath || filePath || '').replace(/#/g, '%23'); function getOutputDir() { if (customOutDir) return customOutDir; if (filePath) return 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); } }); } 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 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 isCopyingStreamId = useCallback((path, streamId) => ( !!(copyStreamIdsByFile[path] || {})[streamId] ), [copyStreamIdsByFile]); const copyAnyAudioTrack = mainStreams.some(stream => isCopyingStreamId(filePath, stream.index) && stream.codec_type === 'audio'); 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(currentSegIndex, 1); const currentSegIndexNew = Math.min(currentSegIndex, cutSegmentsNew.length - 1); setCurrentSegIndex(currentSegIndexNew); setCutSegments(cutSegmentsNew); }, [currentSegIndex, cutSegments]); 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); } function onWheel(e) { seekRel(e.deltaX / 10); } 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({ icon: '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]); 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, }); if (outFiles.length > 1 && autoMerge) { setCutProgress(0); // TODO implement progress await ffmpeg.autoMergeSegments({ customOutDir, sourceFile: filePath, segmentPaths: outFiles, }); } toast.fire({ timer: 10000, icon: 'success', title: `Export completed! Output file(s) can be found at: ${getOutDir(customOutDir, filePath)}. You can change the output directory in settings` }); } catch (err) { console.error('stdout:', err.stdout); console.error('stderr:', err.stderr); if (err.code === 1 || err.code === 'ENOENT') { errorToast(`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`); return; } showFfmpegFail(err); } finally { setWorking(false); } }, [ effectiveRotation, apparentCutSegments, invertCutSegments, inverseCutSegments, working, duration, filePath, keyframeCut, detectedFileFormat, autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyStreamIds, numStreamsToCopy, ]); 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!' }); } // 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, currentTime, captureFormat); } catch (err) { console.error(err); errorToast('Failed to capture frame'); } }, [filePath, currentTime, 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 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 } = 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); } } 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()); 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'); }, []); const extractAllStreams = useCallback(async () => { if (!filePath) return; try { setWorking(true); await ffmpeg.extractAllStreams({ customOutDir, filePath }); toast.fire({ icon: '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(); } 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; userOpenFiles(Array.from(files).map(f => f.path)); }, [userOpenFiles]); 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); } 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); 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); }; }, [ load, mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, getHtml5ifiedPath, createDummyVideo, resetState, extractAllStreams, userOpenFiles, ]); 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; setCutTime(type, rel); seekAbs(rel); }; const cutTime = type === 'start' ? currentApparentCutSeg.start : currentApparentCutSeg.end; return ( 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))); const durationSafe = duration || 1; const currentTimePos = currentTime !== undefined && `${(currentTime / durationSafe) * 100}%`; const segColor = (currentCutSeg || {}).color; const segBgColor = segColor.alpha(0.5).string(); const jumpCutButtonStyle = { position: 'absolute', color: 'black', bottom: 0, top: 0, padding: '2px 8px', }; function renderFormatOptions(map) { return Object.entries(map).map(([f, name]) => ( )); } function renderOutFmt({ width } = {}) { return ( ); } function renderCaptureFormatButton() { return ( ); } function renderSettings() { return (
| Output format (default autodetected) | {renderOutFmt()} | 
| Output directory | 
              
               {customOutDir} 
             | 
          
| Auto merge segments to one file after export? | |
| keyframe cut mode | |
| Discard (cut away) or keep selected segments from video when exporting | |
| Discard audio? | |
| Snapshot capture format | {renderCaptureFormatButton()} | 
| In timecode show |