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, Table, SegmentedControl, Checkbox } 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 / (detectedFps || 60)) * dir); }, [seekRel, detectedFps]); 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 updateCurrentSegOrder = (newOrder) => { const segAtNewIndex = cutSegments[newOrder]; const segAtOldIndex = cutSegments[currentSegIndexSafe]; const newSegments = [...cutSegments]; // Swap indexes: newSegments[currentSegIndexSafe] = segAtNewIndex; newSegments[newOrder] = segAtOldIndex; setCutSegments(newSegments); setCurrentSegIndex(newOrder); }; 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]); async function onReorderSegsPress() { if (cutSegments.length < 2) return; const { value } = await Swal.fire({ title: 'Change the order for the current segment', text: `Please enter a number from 1 to ${cutSegments.length}`, input: 'text', inputValue: currentSegIndexSafe + 1, showCancelButton: true, inputValidator: (v) => { const parsed = parseInt(v, 10); return Number.isNaN(parsed) || parsed > cutSegments.length || parsed < 1 ? 'Invalid number entered' : undefined; }, }); if (value) { const newOrder = parseInt(value, 10); updateCurrentSegOrder(newOrder - 1); } } 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('redo', redo); 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 ( 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 { 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]) => ( )); } function renderOutFmt(style = {}) { return ( ); } function renderCaptureFormatButton() { return ( ); } const renderSettings = () => { // eslint-disable-next-line react/jsx-props-no-spreading const Row = (props) => ; // eslint-disable-next-line react/jsx-props-no-spreading const KeyCell = (props) => ; return ( Output format (default autodetected) {renderOutFmt({ width: '100%' })} Working directory
This is where working files, exported files, project files (CSV) are stored.
{customOutDir}
Auto merge segments to one file during export or export to separate files? setAutoMerge(value === 'automerge')} /> Keyframe cut mode
Nearest keyframe: Cut at the nearest keyframe (not accurate time.)
Normal cut: Accurate time but could leave an empty portion at the beginning of the video.
setKeyframeCut(value === 'keyframe')} />
☯️ Choose cutting mode: Cut away or keep selected segments from video when exporting?
When Keep is selected, the video inside segments will be kept, while the video outside will be discarded.
When Cut away is selected, the video inside segments will be discarded, while the video surrounding them will be kept.
setInvertCutSegments(value === 'discard')} />
Extract unprocessable tracks to separate files or discard them?
(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)
setAutoExportExtraStreams(value === 'extract')} />
Auto save project file?
The project will be stored along with the output files as a CSV file
setAutoSaveProjectFile(e.target.checked)} />
Snapshot capture format {renderCaptureFormatButton()} In timecode show setTimecodeShowFrames(value === 'frames')} /> Ask for confirmation when closing app? setAskBeforeClose(e.target.checked)} />
); }; 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 (
setInvertCutSegments(v => !v))} />
); } 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 ( ); } const getSegButtonStyle = ({ segActiveBgColor, segBorderColor }) => ({ background: segActiveBgColor, border: `2px solid ${segBorderColor}`, borderRadius: 6, color: 'white', fontSize: 14, textAlign: 'center', lineHeight: '11px', fontWeight: 'bold' }); const curSegButtonStyle = getSegButtonStyle({ segActiveBgColor: currentSegActiveBgColor, segBorderColor: currentSegBorderColor, }); function renderJumpCutpointButton(direction) { const newIndex = currentSegIndexSafe + direction; const seg = cutSegments[newIndex]; let segButtonStyle; if (seg) { const { segActiveBgColor, segBorderColor } = getSegColors(seg); segButtonStyle = getSegButtonStyle({ segActiveBgColor, segBorderColor }); } else { segButtonStyle = getSegButtonStyle({ segActiveBgColor: 'rgba(255,255,255,0.3)', segBorderColor: 'rgba(255,255,255,0.5)' }); } return (
0 ? 'next' : 'previous'} segment (${newIndex + 1})`} onClick={() => seg && setCurrentSegIndex(newIndex)} > {newIndex + 1}
); } const primaryColor = 'hsl(194, 78%, 47%)'; const AutoMergeIcon = autoMerge ? MdCallMerge : MdCallSplit; return (
{filePath && ( setStreamsSelectorShown(false)} > )}
{filePath && ( {renderOutFmt({ width: 60 })} )}
{!filePath && (
DROP VIDEO(S)
{mifiLink && mifiLink.loadUrl && (