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) =>