import React, { memo, useEffect, useState, useCallback, useRef, Fragment, useMemo } from 'react'; import { FaVolumeMute, FaVolumeUp, FaAngleLeft, FaWindowClose } from 'react-icons/fa'; import { AnimatePresence, motion } from 'framer-motion'; import Swal from 'sweetalert2'; import Lottie from 'react-lottie'; import { SideSheet, Button, Position, SegmentedControl, Select } from 'evergreen-ui'; import { useStateWithHistory } from 'react-use/lib/useStateWithHistory'; import useDebounce from 'react-use/lib/useDebounce'; import filePathToUrl from 'file-url'; import Mousetrap from 'mousetrap'; import uuid from 'uuid'; import i18n from 'i18next'; import { useTranslation } from 'react-i18next'; import withReactContent from 'sweetalert2-react-content'; 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 Canvas from './Canvas'; import TopMenu from './TopMenu'; import HelpSheet from './HelpSheet'; import SettingsSheet from './SettingsSheet'; import StreamsSelector from './StreamsSelector'; import SegmentList from './SegmentList'; import Settings from './Settings'; import LeftMenu from './LeftMenu'; import Timeline from './Timeline'; import RightMenu from './RightMenu'; import TimelineControls from './TimelineControls'; import { loadMifiLink } from './mifi'; import { primaryColor, controlsBackground, waveformColor } from './colors'; import { showMergeDialog, showOpenAndMergeDialog } from './merge/merge'; import allOutFormats from './outFormats'; import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame'; import { defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd, getDefaultOutFormat, getFormatData, mergeFiles as ffmpegMergeFiles, renderThumbnails as ffmpegRenderThumbnails, readFrames, renderWaveformPng, html5ifyDummy, cutMultiple, extractStreams, autoMergeSegments, getAllStreams, findNearestKeyFrameTime, html5ify as ffmpegHtml5ify, isStreamThumbnail, isAudioSupported, isIphoneHevc, } from './ffmpeg'; import { save as edlStoreSave, load as edlStoreLoad } from './edlStore'; import { getOutPath, formatDuration, toast, errorToast, showFfmpegFail, setFileNameTitle, promptTimeOffset, generateColor, getOutDir, withBlur, checkDirWriteAccess, dirExists, askForOutDir, openDirToast, askForHtml5ifySpeed, isMasBuild, isStoreBuild, } from './util'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import loadingLottie from './7077-magic-flow.json'; // const isDev = window.require('electron-is-dev'); const electron = window.require('electron'); // eslint-disable-line const trash = window.require('trash'); const { unlink, exists } = window.require('fs-extra'); const { extname } = window.require('path'); const { dialog, app } = electron.remote; const configStore = electron.remote.require('./configStore'); const { focusWindow } = electron.remote.require('./electron'); const ReactSwal = withReactContent(Swal); function createSegment({ start, end, name } = {}) { return { start, end, name: name || '', color: generateColor(), uuid: uuid.v4(), }; } const createInitialCutSegments = () => [createSegment()]; // 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 cleanCutSegments = (cs) => cs.map((seg) => ({ start: seg.start, end: seg.end, name: seg.name, })); const dragPreventer = ev => { ev.preventDefault(); }; // With these codecs, the player will not give a playback error, but instead only play audio function doesPlayerSupportFile(streams) { const videoStreams = streams.filter(s => s.codec_type === 'video'); // Don't check audio formats, assume all is OK if (videoStreams.length === 0) return true; // If we have at least one video that is NOT of the unsupported formats, assume the player will be able to play it natively return videoStreams.some(s => !['hevc', 'prores'].includes(s.codec_name)); } const ffmpegExtractWindow = 60; const calcShouldShowWaveform = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8); const calcShouldShowKeyframes = (zoomedDuration) => (zoomedDuration != null && zoomedDuration < ffmpegExtractWindow * 8); const commonFormats = ['mov', 'mp4', 'matroska', 'mp3', 'ipod']; // TODO flex const topBarHeight = 32; const timelineHeight = 36; const zoomMax = 2 ** 14; const videoStyle = { width: '100%', height: '100%', objectFit: 'contain' }; const App = memo(() => { // Per project state const [waveform, setWaveform] = useState(); const [html5FriendlyPath, setHtml5FriendlyPath] = useState(); const [working, setWorking] = useState(); const [dummyVideoPath, setDummyVideoPath] = useState(false); const [playing, setPlaying] = useState(false); const [playerTime, setPlayerTime] = useState(); const [duration, setDuration] = useState(); const [fileFormat, setFileFormat] = useState(); const [fileFormatData, setFileFormatData] = useState(); const [detectedFileFormat, setDetectedFileFormat] = useState(); const [rotation, setRotation] = useState(360); const [cutProgress, setCutProgress] = useState(); const [startTimeOffset, setStartTimeOffset] = useState(0); const [filePath, setFilePath] = useState(''); const [externalStreamFiles, setExternalStreamFiles] = useState([]); const [detectedFps, setDetectedFps] = useState(); const [mainStreams, setMainStreams] = useState([]); const [mainVideoStream, setMainVideoStream] = useState(); const [mainAudioStream, setMainAudioStream] = useState(); const [copyStreamIdsByFile, setCopyStreamIdsByFile] = useState({}); const [streamsSelectorShown, setStreamsSelectorShown] = useState(false); const [zoom, setZoom] = useState(1); const [commandedTime, setCommandedTime] = useState(0); const [ffmpegCommandLog, setFfmpegCommandLog] = useState([]); const [neighbouringFrames, setNeighbouringFrames] = useState([]); const [thumbnails, setThumbnails] = useState([]); const [shortestFlag, setShortestFlag] = useState(false); const [zoomWindowStartTime, setZoomWindowStartTime] = useState(0); const [keyframesEnabled, setKeyframesEnabled] = useState(true); const [waveformEnabled, setWaveformEnabled] = useState(false); const [thumbnailsEnabled, setThumbnailsEnabled] = useState(false); const [showSideBar, setShowSideBar] = useState(true); const [hideCanvasPreview, setHideCanvasPreview] = useState(false); // Segment related state const [currentSegIndex, setCurrentSegIndex] = useState(0); const [cutStartTimeManual, setCutStartTimeManual] = useState(); const [cutEndTimeManual, setCutEndTimeManual] = useState(); const [cutSegments, setCutSegments, cutSegmentsHistory] = useStateWithHistory( createInitialCutSegments(), 100, ); const [debouncedCutSegments, setDebouncedCutSegments] = useState( createInitialCutSegments(), ); const [, cancelCutSegmentsDebounce] = useDebounce(() => { setDebouncedCutSegments(cutSegments); }, 500, [cutSegments]); const durationSafe = duration || 1; const zoomedDuration = duration != null ? duration / zoom : undefined; const isCustomFormatSelected = fileFormat !== detectedFileFormat; const firstUpdateRef = useRef(true); function safeSetConfig(key, value) { // Prevent flood-saving all config when mounting if (firstUpdateRef.current) return; // console.log(key); try { configStore.set(key, value); } catch (err) { console.error('Failed to set config', key, err); errorToast(i18n.t('Unable to save your preferences. Try to disable any anti-virus')); } } // Preferences const [captureFormat, setCaptureFormat] = useState(configStore.get('captureFormat')); useEffect(() => safeSetConfig('captureFormat', captureFormat), [captureFormat]); const [customOutDir, setCustomOutDir] = useState(configStore.get('customOutDir')); useEffect(() => safeSetConfig('customOutDir', customOutDir), [customOutDir]); const [keyframeCut, setKeyframeCut] = useState(configStore.get('keyframeCut')); useEffect(() => safeSetConfig('keyframeCut', keyframeCut), [keyframeCut]); const [autoMerge, setAutoMerge] = useState(configStore.get('autoMerge')); useEffect(() => safeSetConfig('autoMerge', autoMerge), [autoMerge]); const [timecodeShowFrames, setTimecodeShowFrames] = useState(configStore.get('timecodeShowFrames')); useEffect(() => safeSetConfig('timecodeShowFrames', timecodeShowFrames), [timecodeShowFrames]); const [invertCutSegments, setInvertCutSegments] = useState(configStore.get('invertCutSegments')); useEffect(() => safeSetConfig('invertCutSegments', invertCutSegments), [invertCutSegments]); const [autoExportExtraStreams, setAutoExportExtraStreams] = useState(configStore.get('autoExportExtraStreams')); useEffect(() => safeSetConfig('autoExportExtraStreams', autoExportExtraStreams), [autoExportExtraStreams]); const [askBeforeClose, setAskBeforeClose] = useState(configStore.get('askBeforeClose')); useEffect(() => safeSetConfig('askBeforeClose', askBeforeClose), [askBeforeClose]); const [muted, setMuted] = useState(configStore.get('muted')); useEffect(() => safeSetConfig('muted', muted), [muted]); const [autoSaveProjectFile, setAutoSaveProjectFile] = useState(configStore.get('autoSaveProjectFile')); useEffect(() => safeSetConfig('autoSaveProjectFile', autoSaveProjectFile), [autoSaveProjectFile]); const [wheelSensitivity, setWheelSensitivity] = useState(configStore.get('wheelSensitivity')); useEffect(() => safeSetConfig('wheelSensitivity', wheelSensitivity), [wheelSensitivity]); const [invertTimelineScroll, setInvertTimelineScroll] = useState(configStore.get('invertTimelineScroll')); useEffect(() => safeSetConfig('invertTimelineScroll', invertTimelineScroll), [invertTimelineScroll]); const [language, setLanguage] = useState(configStore.get('language')); useEffect(() => safeSetConfig('language', language), [language]); const [ffmpegExperimental, setFfmpegExperimental] = useState(configStore.get('ffmpegExperimental')); useEffect(() => safeSetConfig('ffmpegExperimental', ffmpegExperimental), [ffmpegExperimental]); useEffect(() => { i18n.changeLanguage(language || fallbackLng).catch(console.error); }, [language]); // This useEffect must be placed after all usages of firstUpdateRef.current useEffect(() => { firstUpdateRef.current = false; }, []); // Global state const [helpVisible, setHelpVisible] = useState(false); const [settingsVisible, setSettingsVisible] = useState(false); const [wheelTunerVisible, setWheelTunerVisible] = useState(false); const [mifiLink, setMifiLink] = useState(); const videoRef = useRef(); const lastSavedCutSegmentsRef = useRef(); const readingKeyframesPromise = useRef(); const creatingWaveformPromise = useRef(); const currentTimeRef = useRef(); const isFileOpened = !!filePath; function setTimelineMode(newMode) { if (newMode === 'waveform') { setWaveformEnabled(v => !v); setThumbnailsEnabled(false); } else { setThumbnailsEnabled(v => !v); setWaveformEnabled(false); } } const toggleKeyframesEnabled = useCallback(() => { setKeyframesEnabled((old) => { const enabled = !old; if (enabled && !calcShouldShowKeyframes(zoomedDuration)) { toast.fire({ text: i18n.t('Key frames will show on the timeline. You need to zoom in to view them') }); } return enabled; }); }, [zoomedDuration]); function appendFfmpegCommandLog(command) { setFfmpegCommandLog(old => [...old, { command, time: new Date() }]); } const getCurrentTime = useCallback(() => currentTimeRef.current, []); function setCopyStreamIdsForPath(path, cb) { setCopyStreamIdsByFile((old) => { const oldIds = old[path] || {}; return ({ ...old, [path]: cb(oldIds) }); }); } const toggleSideBar = useCallback(() => setShowSideBar(v => !v), []); const toggleCopyStreamId = useCallback((path, index) => { setCopyStreamIdsForPath(path, (old) => ({ ...old, [index]: !old[index] })); }, []); function toggleMute() { setMuted((v) => { if (!v) toast.fire({ icon: 'info', title: i18n.t('Muted preview (exported file will not be affected)') }); return !v; }); } const seekAbs = useCallback((val) => { const video = videoRef.current; if (val == null || Number.isNaN(val)) return; let valRounded = val; if (detectedFps) valRounded = Math.round(detectedFps * val) / detectedFps; // Round to nearest frame let outVal = valRounded; if (outVal < 0) outVal = 0; if (outVal > video.duration) outVal = video.duration; video.currentTime = outVal; setCommandedTime(outVal); }, [detectedFps]); const seekRel = useCallback((val) => { seekAbs(videoRef.current.currentTime + val); }, [seekAbs]); const seekRelPercent = useCallback((val) => { seekRel(val * zoomedDuration); }, [seekRel, zoomedDuration]); const shortStep = useCallback((dir) => { seekRel((1 / (detectedFps || 60)) * dir); }, [seekRel, detectedFps]); /* useEffect(() => () => { if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error); }, [dummyVideoPath]); */ // 360 means we don't modify rotation const isRotationSet = rotation !== 360; const effectiveRotation = isRotationSet ? rotation : (mainVideoStream && mainVideoStream.tags && mainVideoStream.tags.rotate && parseInt(mainVideoStream.tags.rotate, 10)); const zoomRel = useCallback((rel) => setZoom(z => Math.min(Math.max(z + rel, 1), zoomMax)), []); const canvasPlayerRequired = !!(mainVideoStream && dummyVideoPath); const canvasPlayerWanted = !!(mainVideoStream && isRotationSet && !hideCanvasPreview); // Allow user to disable it const canvasPlayerEnabled = (canvasPlayerRequired || canvasPlayerWanted); useEffect(() => { // Reset the user preference when the state changes to true if (canvasPlayerEnabled) setHideCanvasPreview(false); }, [canvasPlayerEnabled]); const comfortZoom = duration ? Math.max(duration / 100, 1) : undefined; const toggleComfortZoom = useCallback(() => { if (!comfortZoom) return; setZoom((prevZoom) => { if (prevZoom === 1) return comfortZoom; return 1; }); }, [comfortZoom]); 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 apparentCutSegments = useMemo(() => cutSegments.map(cutSegment => ({ ...cutSegment, start: getSegApparentStart(cutSegment), end: getSegApparentEnd(cutSegment), })), [cutSegments, getSegApparentEnd]); 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 = useMemo(() => cutSegments[currentSegIndexSafe], [currentSegIndexSafe, cutSegments]); const currentApparentCutSeg = useMemo(() => apparentCutSegments[currentSegIndexSafe], [apparentCutSegments, currentSegIndexSafe]); const areWeCutting = apparentCutSegments.length > 1 || isCuttingStart(currentApparentCutSeg.start) || isCuttingEnd(currentApparentCutSeg.end, duration); const jumpCutStart = useCallback(() => seekAbs(currentApparentCutSeg.start), [currentApparentCutSeg.start, seekAbs]); const jumpCutEnd = useCallback(() => seekAbs(currentApparentCutSeg.end), [currentApparentCutSeg.end, seekAbs]); const sortedCutSegments = useMemo(() => sortBy(apparentCutSegments, 'start'), [apparentCutSegments]); const inverseCutSegments = useMemo(() => { if (haveInvalidSegs) return undefined; if (sortedCutSegments.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; }, [duration, haveInvalidSegs, sortedCutSegments]); 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 = useCallback((name) => { const cloned = cloneDeep(cutSegments); cloned[currentSegIndexSafe].name = name; setCutSegments(cloned); }, [currentSegIndexSafe, cutSegments, setCutSegments]); const updateCurrentSegOrder = useCallback((newOrder) => { const segAtNewIndex = cutSegments[newOrder]; const segAtOldIndex = cutSegments[currentSegIndexSafe]; const newSegments = [...cutSegments]; // Swap indexes: newSegments[currentSegIndexSafe] = segAtNewIndex; newSegments[newOrder] = segAtOldIndex; setCutSegments(newSegments); setCurrentSegIndex(newOrder); }, [currentSegIndexSafe, cutSegments, setCutSegments]); const formatTimecode = useCallback((sec) => formatDuration({ seconds: sec, fps: timecodeShowFrames ? detectedFps : undefined, }), [detectedFps, timecodeShowFrames]); const getFrameCount = useCallback((sec) => { if (detectedFps == null) return undefined; return Math.floor(sec * detectedFps); }, [detectedFps]); useEffect(() => { currentTimeRef.current = playing ? playerTime : commandedTime; }, [commandedTime, playerTime, playing]); // const getSafeCutTime = useCallback((cutTime, next) => ffmpeg.getSafeCutTime(neighbouringFrames, cutTime, next), [neighbouringFrames]); const addCutSegment = useCallback(() => { try { // Cannot add if prev seg is not finished if (currentCutSeg.start === undefined && currentCutSeg.end === undefined) return; const suggestedStart = currentTimeRef.current; /* if (keyframeCut) { const keyframeAlignedStart = getSafeCutTime(suggestedStart, true); if (keyframeAlignedStart != null) suggestedStart = keyframeAlignedStart; } */ const cutSegmentsNew = [ ...cutSegments, createSegment({ start: suggestedStart }), ]; setCutSegments(cutSegmentsNew); setCurrentSegIndex(cutSegmentsNew.length - 1); } catch (err) { console.error(err); } }, [ currentCutSeg.start, currentCutSeg.end, cutSegments, setCutSegments, ]); const setCutStart = useCallback(() => { if (!filePath) return; // 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 && currentTimeRef.current > currentCutSeg.end) { addCutSegment(); } else { try { const startTime = currentTimeRef.current; /* if (keyframeCut) { const keyframeAlignedCutTo = getSafeCutTime(startTime, true); if (keyframeAlignedCutTo != null) startTime = keyframeAlignedCutTo; } */ setCutTime('start', startTime); } catch (err) { errorToast(err.message); } } }, [setCutTime, currentCutSeg, addCutSegment, filePath]); const setCutEnd = useCallback(() => { if (!filePath) return; try { const endTime = currentTimeRef.current; /* if (keyframeCut) { const keyframeAlignedCutTo = getSafeCutTime(endTime, false); if (keyframeAlignedCutTo != null) endTime = keyframeAlignedCutTo; } */ setCutTime('end', endTime); } catch (err) { errorToast(err.message); } }, [setCutTime, filePath]); const outputDir = getOutDir(customOutDir, filePath); const changeOutDir = useCallback(async () => { const newOutDir = await askForOutDir(outputDir); // We cannot allow exporting to a directory which has not yet been confirmed by an open dialog // because of sandox restrictions if (isMasBuild && !newOutDir) return; // Else it's OK, we allow clearing the dir too setCustomOutDir(newOutDir); }, [outputDir]); const effectiveFilePath = dummyVideoPath || html5FriendlyPath || filePath; const fileUri = effectiveFilePath ? filePathToUrl(effectiveFilePath) : ''; 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(debouncedCutSegments), cleanCutSegments(createInitialCutSegments()))) return; /* if (lastSavedCutSegmentsRef.current && isEqual(cleanCutSegments(lastSavedCutSegmentsRef.current), cleanCutSegments(debouncedCutSegments))) { // console.log('Seg state didn\'t change, skipping save'); return; } */ await edlStoreSave(edlFilePath, debouncedCutSegments); lastSavedCutSegmentsRef.current = debouncedCutSegments; } catch (err) { errorToast(i18n.t('Unable to save project file')); console.error('Failed to save CSV', err); } } save(); }, [debouncedCutSegments, edlFilePath, autoSaveProjectFile]); function onPlayingChange(val) { setPlaying(val); if (!val) { setCommandedTime(videoRef.current.currentTime); } } const onStopPlaying = useCallback(() => onPlayingChange(false), []); const onSartPlaying = useCallback(() => onPlayingChange(true), []); const onDurationChange = useCallback((e) => { // Some files report duration infinity first, then proper duration later if (e.target.duration !== Infinity) setDuration(e.target.duration); }, []); const onTimeUpdate = useCallback((e) => { const { currentTime } = e.target; if (playerTime === currentTime) return; setPlayerTime(currentTime); }, [playerTime]); const increaseRotation = useCallback(() => { setRotation((r) => (r + 90) % 450); setHideCanvasPreview(false); }, []); const assureOutDirAccess = useCallback(async (outFilePath) => { // Reset if doesn't exist anymore const customOutDirExists = await dirExists(customOutDir); if (!customOutDirExists) setCustomOutDir(undefined); const newCustomOutDir = customOutDirExists ? customOutDir : undefined; const outDirPath = getOutDir(newCustomOutDir, outFilePath); const hasDirWriteAccess = await checkDirWriteAccess(outDirPath); if (!hasDirWriteAccess) { if (isMasBuild) { const newOutDir = await askForOutDir(outDirPath); // User cancelled open dialog. Refuse to continue, because we will get permission denied error from MAS sandbox if (!newOutDir) return { cancel: true }; setCustomOutDir(newOutDir); } else { errorToast(i18n.t('You have no write access to the directory of this file, please select a custom working dir')); } } return { cancel: false, newCustomOutDir }; }, [customOutDir]); const mergeFiles = useCallback(async ({ paths, allStreams }) => { try { setWorking(i18n.t('Merging')); const firstPath = paths[0]; const { newCustomOutDir, cancel } = await assureOutDirAccess(firstPath); if (cancel) return; const ext = extname(firstPath); const outPath = getOutPath(newCustomOutDir, firstPath, `merged${ext}`); // console.log('merge', paths); await ffmpegMergeFiles({ paths, outPath, allStreams, ffmpegExperimental, onProgress: setCutProgress }); openDirToast({ icon: 'success', dirPath: outputDir, text: i18n.t('Files merged!') }); } catch (err) { errorToast(i18n.t('Failed to merge files. Make sure they are all of the exact same codecs')); console.error('Failed to merge files', err); } finally { setWorking(); setCutProgress(); } }, [assureOutDirAccess, outputDir, ffmpegExperimental]); const toggleCaptureFormat = useCallback(() => setCaptureFormat(f => (f === 'png' ? 'jpeg' : 'png')), []); const toggleKeyframeCut = useCallback(() => setKeyframeCut((val) => { const newVal = !val; if (newVal) toast.fire({ title: i18n.t('Keyframe cut enabled'), text: i18n.t('Will now cut at the nearest keyframe before the desired start cutpoint. This is recommended for most files.') }); else toast.fire({ title: i18n.t('Keyframe cut disabled'), text: i18n.t('Will now cut at the exact position, but may leave an empty portion at the beginning of the file. You may have to set the cutpoint a few frames before the next keyframe to achieve a precise cut'), timer: 7000 }); return newVal; }), []); const toggleAutoMerge = useCallback(() => 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 = useMemo(() => mainStreams .filter((stream) => !defaultProcessedCodecTypes.includes(stream.codec_type)), [mainStreams]); // Extra streams that the user has not selected for copy const nonCopiedExtraStreams = useMemo(() => extraStreams .filter((stream) => !isCopyingStreamId(filePath, stream.index)), [extraStreams, filePath, isCopyingStreamId]); const exportExtraStreams = autoExportExtraStreams && nonCopiedExtraStreams.length > 0; const copyFileStreams = useMemo(() => Object.entries(copyStreamIdsByFile).map(([path, streamIdsMap]) => ({ path, streamIds: Object.keys(streamIdsMap).filter(index => streamIdsMap[index]), })), [copyStreamIdsByFile]); const numStreamsToCopy = copyFileStreams .reduce((acc, { streamIds }) => acc + streamIds.length, 0); const numStreamsTotal = [ ...mainStreams, ...flatMap(Object.values(externalStreamFiles), ({ streams }) => streams), ].length; const toggleStripAudio = useCallback(() => { setCopyStreamIdsForPath(filePath, (old) => { const newCopyStreamIds = { ...old }; mainStreams.forEach((stream) => { if (stream.codec_type === 'audio') newCopyStreamIds[stream.index] = !copyAnyAudioTrack; }); return newCopyStreamIds; }); }, [copyAnyAudioTrack, filePath, mainStreams]); const removeCutSegment = useCallback(() => { if (cutSegments.length <= 1) { setCutSegments(createInitialCutSegments()); return; } const cutSegmentsNew = [...cutSegments]; cutSegmentsNew.splice(currentSegIndexSafe, 1); setCutSegments(cutSegmentsNew); }, [currentSegIndexSafe, cutSegments, setCutSegments]); const thumnailsRef = useRef([]); const thumnailsRenderingPromiseRef = useRef(); function addThumbnail(thumbnail) { // console.log('Rendered thumbnail', thumbnail.url); setThumbnails(v => [...v, thumbnail]); } const [, cancelRenderThumbnails] = useDebounce(() => { async function renderThumbnails() { if (!thumbnailsEnabled || thumnailsRenderingPromiseRef.current) return; try { setThumbnails([]); const promise = ffmpegRenderThumbnails({ filePath, from: zoomWindowStartTime, duration: zoomedDuration, onThumbnail: addThumbnail }); thumnailsRenderingPromiseRef.current = promise; await promise; } catch (err) { console.error('Failed to render thumbnail', err); } finally { thumnailsRenderingPromiseRef.current = undefined; } } if (duration) renderThumbnails(); }, 500, [zoomedDuration, duration, filePath, zoomWindowStartTime, thumbnailsEnabled]); // Cleanup removed thumbnails useEffect(() => { thumnailsRef.current.forEach((thumbnail) => { if (!thumbnails.some(t => t.url === thumbnail.url)) URL.revokeObjectURL(thumbnail.url); }); thumnailsRef.current = thumbnails; }, [thumbnails]); const [, cancelReadKeyframeDataDebounce] = useDebounce(() => { async function run() { // We still want to calculate keyframes even if not shouldShowKeyframes because maybe we want to step to closest keyframe if (!keyframesEnabled || !filePath || !mainVideoStream || commandedTime == null || readingKeyframesPromise.current) return; try { const promise = readFrames({ filePath, aroundTime: commandedTime, stream: mainVideoStream.index, window: ffmpegExtractWindow }); readingKeyframesPromise.current = promise; const newFrames = await promise; // console.log(newFrames); setNeighbouringFrames(newFrames); } catch (err) { console.error('Failed to read keyframes', err); } finally { readingKeyframesPromise.current = undefined; } } run(); }, 500, [keyframesEnabled, filePath, commandedTime, mainVideoStream]); const hasAudio = !!mainAudioStream; const hasVideo = !!mainVideoStream; const shouldShowKeyframes = keyframesEnabled && !!mainVideoStream && calcShouldShowKeyframes(zoomedDuration); const shouldShowWaveform = calcShouldShowWaveform(zoomedDuration); const [, cancelWaveformDataDebounce] = useDebounce(() => { async function run() { if (!filePath || !mainAudioStream || commandedTime == null || !shouldShowWaveform || !waveformEnabled || creatingWaveformPromise.current) return; try { const promise = renderWaveformPng({ filePath, aroundTime: commandedTime, window: ffmpegExtractWindow, color: waveformColor }); creatingWaveformPromise.current = promise; const wf = await promise; setWaveform(wf); } catch (err) { console.error('Failed to render waveform', err); } finally { creatingWaveformPromise.current = undefined; } } run(); }, 500, [filePath, commandedTime, zoomedDuration, waveformEnabled, mainAudioStream, shouldShowWaveform]); const resetState = useCallback(() => { const video = videoRef.current; setCommandedTime(0); video.currentTime = 0; video.playbackRate = 1; setFileNameTitle(); setHtml5FriendlyPath(); setDummyVideoPath(); setWorking(); setPlaying(false); setDuration(); cutSegmentsHistory.go(0); cancelCutSegmentsDebounce(); // TODO auto save when loading new file/closing file setDebouncedCutSegments(createInitialCutSegments()); setCutSegments(createInitialCutSegments()); // TODO this will cause two history items setCutStartTimeManual(); setCutEndTimeManual(); setFileFormat(); setFileFormatData(); setDetectedFileFormat(); setRotation(360); setCutProgress(); setStartTimeOffset(0); setFilePath(''); // Setting video src="" prevents memory leak in chromium setExternalStreamFiles([]); setDetectedFps(); setMainStreams([]); setMainVideoStream(); setMainAudioStream(); setCopyStreamIdsByFile({}); setStreamsSelectorShown(false); setZoom(1); setShortestFlag(false); setZoomWindowStartTime(0); setHideCanvasPreview(false); setWaveform(); cancelWaveformDataDebounce(); setNeighbouringFrames([]); cancelReadKeyframeDataDebounce(); setThumbnails([]); cancelRenderThumbnails(); }, [cutSegmentsHistory, cancelCutSegmentsDebounce, setCutSegments, cancelWaveformDataDebounce, cancelReadKeyframeDataDebounce, cancelRenderThumbnails]); // Cleanup old useEffect(() => () => waveform && URL.revokeObjectURL(waveform.url), [waveform]); function showUnsupportedFileMessage() { toast.fire({ timer: 13000, text: i18n.t('File not natively supported. Preview may have no audio or low quality. The final export will however be lossless with audio. You may convert it from the menu for a better preview with audio.') }); } const createDummyVideo = useCallback(async (cod, fp) => { const html5ifiedDummyPathDummy = getOutPath(cod, fp, 'html5ified-dummy.mkv'); try { setCutProgress(0); await html5ifyDummy(fp, html5ifiedDummyPathDummy, setCutProgress); } finally { setCutProgress(); } setDummyVideoPath(html5ifiedDummyPathDummy); setHtml5FriendlyPath(); showUnsupportedFileMessage(); }, []); const showPlaybackFailedMessage = () => errorToast(i18n.t('Unable to playback this file. Try to convert to supported format from the menu')); const tryCreateDummyVideo = useCallback(async () => { try { if (working) return; setWorking(i18n.t('Converting to supported format')); await createDummyVideo(customOutDir, filePath); } catch (err) { console.error(err); showPlaybackFailedMessage(); } finally { setWorking(); } }, [createDummyVideo, filePath, working, customOutDir]); const togglePlay = useCallback((resetPlaybackRate) => { if (!filePath) return; const video = videoRef.current; if (playing) { video.pause(); return; } if (resetPlaybackRate) video.playbackRate = 1; video.play().catch((err) => { showPlaybackFailedMessage(); console.error(err); }); }, [playing, filePath]); const deleteSource = useCallback(async () => { if (!filePath || working) return; const { value: trashConfirmed } = await Swal.fire({ icon: 'warning', text: i18n.t('Are you sure you want to move the source file to trash?'), confirmButtonText: i18n.t('Trash it'), showCancelButton: true, }); if (!trashConfirmed) return; // We can use variables like filePath and html5FriendlyPath, even after they are reset because react setState is async resetState(); try { setWorking(i18n.t('Deleting source')); if (html5FriendlyPath) await trash(html5FriendlyPath).catch(console.error); if (dummyVideoPath) await trash(dummyVideoPath).catch(console.error); // throw new Error('test'); await trash(filePath); toast.fire({ icon: 'info', title: i18n.t('File has been moved to trash') }); } catch (err) { try { console.warn('Failed to trash', err); const { value } = await Swal.fire({ icon: 'warning', text: i18n.t('Unable to move source file to trash. Do you want to permanently delete it?'), confirmButtonText: i18n.t('Permanently delete'), showCancelButton: true, }); if (value) { if (html5FriendlyPath) await unlink(html5FriendlyPath).catch(console.error); if (dummyVideoPath) await unlink(dummyVideoPath).catch(console.error); await unlink(filePath); toast.fire({ icon: 'info', title: i18n.t('File has been permanently deleted') }); } } catch (err2) { errorToast(`Unable to delete file: ${err2.message}`); console.error(err2); } } finally { setWorking(); } }, [filePath, html5FriendlyPath, resetState, working, dummyVideoPath]); const outSegments = useMemo(() => (invertCutSegments ? inverseCutSegments : apparentCutSegments), [invertCutSegments, inverseCutSegments, apparentCutSegments]); const openSendReportDialogWithState = useCallback(async (err) => { const state = { filePath, fileFormat, externalStreamFiles, mainStreams, copyStreamIdsByFile, cutSegments: cutSegments.map(s => ({ start: s.start, end: s.end })), fileFormatData, rotation, shortestFlag, }; openSendReportDialog(err, state); }, [copyStreamIdsByFile, cutSegments, externalStreamFiles, fileFormat, fileFormatData, filePath, mainStreams, rotation, shortestFlag]); const handleCutFailed = useCallback(async (err) => { const html = (
Try one of the following before exporting again:
    {detectedFileFormat === 'mp4' &&
  1. Change output Format from MP4 to MOV
  2. }
  3. Select a different output Format (matroska and mp4 support most codecs)
  4. Disable unnecessary Tracks
  5. Try both Normal cut and Keyframe cut
  6. Set a different Working directory
  7. Try with a Different file
  8. See Help
  9. If nothing helps, you can send an Error report
); const { value } = await ReactSwal.fire({ title: i18n.t('Unable to export this file'), html, timer: null, showConfirmButton: true, showCancelButton: true, cancelButtonText: i18n.t('OK'), confirmButtonText: i18n.t('Report'), reverseButtons: true, focusCancel: true }); if (value) { openSendReportDialogWithState(err); } }, [openSendReportDialogWithState, detectedFileFormat]); const cutClick = useCallback(async () => { if (working) return; if (haveInvalidSegs) { errorToast(i18n.t('Start time must be before end time')); return; } if (numStreamsToCopy === 0) { errorToast(i18n.t('No tracks selected for export')); return; } if (!outSegments || outSegments.length < 1) { errorToast(i18n.t('No segments to export')); return; } try { setWorking(i18n.t('Exporting')); // throw (() => { const err = new Error('test'); err.code = 'ENOENT'; return err; })(); const outFiles = await cutMultiple({ customOutDir, filePath, outFormat: fileFormat, isCustomFormatSelected, videoDuration: duration, rotation: isRotationSet ? effectiveRotation : undefined, copyFileStreams, keyframeCut, segments: outSegments, onProgress: setCutProgress, appendFfmpegCommandLog, shortestFlag, ffmpegExperimental, }); if (outFiles.length > 1 && autoMerge) { setCutProgress(0); setWorking(i18n.t('Merging')); await autoMergeSegments({ customOutDir, sourceFile: filePath, outFormat: fileFormat, isCustomFormatSelected, segmentPaths: outFiles, ffmpegExperimental, onProgress: setCutProgress, }); } if (exportExtraStreams) { try { await extractStreams({ filePath, customOutDir, streams: nonCopiedExtraStreams, }); } catch (err) { console.error('Extra stream export failed', err); } } // https://github.com/mifi/lossless-cut/issues/329 const extraIphoneMsg = isIphoneHevc(fileFormatData, mainStreams) ? ` ${i18n.t('There is a known issue with cutting iPhone HEVC videos. The output file may not work in all players.')}` : ''; const extraStreamsMsg = exportExtraStreams ? ` ${i18n.t('Unprocessable streams were exported as separate files.')}` : ''; openDirToast({ dirPath: outputDir, text: `${i18n.t('Done! Note: cutpoints may be inaccurate. Make sure you test the output files in your desired player/editor before you delete the source. If output does not look right, see the HELP page.')}${extraIphoneMsg}${extraStreamsMsg}`, timer: 15000 }); } catch (err) { console.error('stdout:', err.stdout); console.error('stderr:', err.stderr); if (err.exitCode === 1 || err.code === 'ENOENT') { handleCutFailed(err); return; } showFfmpegFail(err); } finally { setWorking(); setCutProgress(); } }, [ effectiveRotation, outSegments, handleCutFailed, isRotationSet, working, duration, filePath, keyframeCut, autoMerge, customOutDir, fileFormat, haveInvalidSegs, copyFileStreams, numStreamsToCopy, exportExtraStreams, nonCopiedExtraStreams, outputDir, shortestFlag, isCustomFormatSelected, fileFormatData, mainStreams, ffmpegExperimental, ]); const capture = useCallback(async () => { if (!filePath) return; try { const mustCaptureFfmpeg = html5FriendlyPath || dummyVideoPath; const currentTime = currentTimeRef.current; const video = videoRef.current; const outPath = mustCaptureFfmpeg ? await captureFrameFfmpeg({ customOutDir, videoPath: filePath, currentTime, captureFormat, duration }) : await captureFrameFromTag({ customOutDir, filePath, video, currentTime, captureFormat }); openDirToast({ dirPath: outputDir, text: `${i18n.t('Screenshot captured to:')} ${outPath}` }); } catch (err) { console.error(err); errorToast(i18n.t('Failed to capture frame')); } }, [filePath, captureFormat, customOutDir, html5FriendlyPath, dummyVideoPath, outputDir, duration]); const changePlaybackRate = useCallback((dir) => { if (canvasPlayerEnabled) { toast.fire({ title: i18n.t('Unable to playback rate right now'), timer: 1000 }); return; } const video = videoRef.current; if (!playing) { video.play(); } else { const newRate = clamp(video.playbackRate + (dir * 0.15), 0.1, 16); toast.fire({ title: `${i18n.t('Playback rate:')} ${Math.floor(newRate * 100)}%`, timer: 1000 }); video.playbackRate = newRate; } }, [playing, canvasPlayerEnabled]); const getHtml5ifiedPath = useCallback((cod, fp, type) => { const ext = type === 'fastest-audio' ? 'mkv' : 'mp4'; return getOutPath(cod, fp, `html5ified-${type}.${ext}`); }, []); const loadEdlFile = useCallback(async (edlPath) => { try { const storedEdl = await edlStoreLoad(edlPath); const allRowsValid = storedEdl .every(row => row.start === undefined || row.end === undefined || row.start < row.end); if (!allRowsValid) { throw new Error(i18n.t('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(`${i18n.t('Failed to load project file')} (${err.message})`); } } }, [cutSegmentsHistory, setCutSegments]); const load = useCallback(async ({ filePath: fp, customOutDir: cod, html5FriendlyPathRequested, dummyVideoPathRequested }) => { console.log('Load', { fp, cod, html5FriendlyPathRequested, dummyVideoPathRequested }); if (working) return; resetState(); setWorking(i18n.t('Loading file')); async function checkAndSetExistingHtml5FriendlyFile(speed) { const existing = getHtml5ifiedPath(cod, fp, speed); const ret = existing && await exists(existing); if (ret) { console.log('Found existing supported file', existing); if (speed === 'fastest-audio') { setDummyVideoPath(existing); setHtml5FriendlyPath(); } else { setHtml5FriendlyPath(existing); } showUnsupportedFileMessage(); } return ret; } try { const fd = await getFormatData(fp); const ff = await getDefaultOutFormat(fp, fd); if (!ff) { errorToast(i18n.t('Unable to determine file format')); return; } const { streams } = await getAllStreams(fp); // console.log('streams', streamsNew); const videoStream = streams.find(stream => stream.codec_type === 'video' && !isStreamThumbnail(stream)); const audioStream = streams.find(stream => stream.codec_type === 'audio'); setMainVideoStream(videoStream); setMainAudioStream(audioStream); if (videoStream) { const streamFps = getStreamFps(videoStream); if (streamFps != null) setDetectedFps(streamFps); } const shouldDefaultCopyStream = (stream) => { if (!defaultProcessedCodecTypes.includes(stream.codec_type)) return false; // Don't enable thumbnail stream by default if we have a main video stream // It's been known to cause issues: https://github.com/mifi/lossless-cut/issues/308 if (isStreamThumbnail(stream) && videoStream) return false; return true; }; setMainStreams(streams); setCopyStreamIdsForPath(fp, () => fromPairs(streams.map((stream) => [ stream.index, shouldDefaultCopyStream(stream), ]))); setFileNameTitle(fp); setFilePath(fp); setFileFormat(ff); setDetectedFileFormat(ff); setFileFormatData(fd); if (!isAudioSupported(streams)) { toast.fire({ icon: 'info', text: i18n.t('The audio track is not supported. You can convert to a supported format from the menu') }); } if (html5FriendlyPathRequested) { setHtml5FriendlyPath(html5FriendlyPathRequested); showUnsupportedFileMessage(); } else if (dummyVideoPathRequested) { setDummyVideoPath(dummyVideoPathRequested); setHtml5FriendlyPath(); showUnsupportedFileMessage(); } else if ( !(await checkAndSetExistingHtml5FriendlyFile('slowest') || await checkAndSetExistingHtml5FriendlyFile('slow-audio') || await checkAndSetExistingHtml5FriendlyFile('slow') || await checkAndSetExistingHtml5FriendlyFile('fast-audio') || await checkAndSetExistingHtml5FriendlyFile('fast') || await checkAndSetExistingHtml5FriendlyFile('fastest-audio')) && !doesPlayerSupportFile(streams) ) { await createDummyVideo(cod, fp); } await loadEdlFile(getEdlFilePath(fp)); } catch (err) { if (err.exitCode === 1 || err.code === 'ENOENT') { errorToast(i18n.t('Unsupported file')); console.error(err); return; } showFfmpegFail(err); } finally { setWorking(); } }, [resetState, working, createDummyVideo, loadEdlFile, getEdlFilePath, getHtml5ifiedPath]); const toggleHelp = useCallback(() => setHelpVisible(val => !val), []); const toggleSettings = useCallback(() => setSettingsVisible(val => !val), []); const jumpSeg = useCallback((val) => setCurrentSegIndex((old) => Math.max(Math.min(old + val, cutSegments.length - 1), 0)), [cutSegments.length]); const seekClosestKeyframe = useCallback((direction) => { const time = findNearestKeyFrameTime({ frames: neighbouringFrames, time: currentTimeRef.current, direction, fps: detectedFps }); if (time == null) return; seekAbs(time); }, [neighbouringFrames, seekAbs, detectedFps]); useEffect(() => { Mousetrap.bind('space', () => togglePlay(true)); Mousetrap.bind('k', () => togglePlay()); Mousetrap.bind('j', () => changePlaybackRate(-1)); Mousetrap.bind('l', () => changePlaybackRate(1)); Mousetrap.bind('left', () => seekRel(-1)); Mousetrap.bind('right', () => seekRel(1)); Mousetrap.bind(['ctrl+left', 'command+left'], () => { seekRelPercent(-0.01); return false; }); Mousetrap.bind(['ctrl+right', 'command+right'], () => { seekRelPercent(0.01); return false; }); Mousetrap.bind('alt+left', () => seekClosestKeyframe(-1)); Mousetrap.bind('alt+right', () => seekClosestKeyframe(1)); Mousetrap.bind('up', () => jumpSeg(-1)); Mousetrap.bind('down', () => jumpSeg(1)); Mousetrap.bind(['ctrl+up', 'command+up'], () => { zoomRel(1); return false; }); Mousetrap.bind(['ctrl+down', 'command+down'], () => { zoomRel(-1); return false; }); Mousetrap.bind('z', () => toggleComfortZoom()); 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(['ctrl+left', 'command+left']); Mousetrap.unbind(['ctrl+right', 'command+right']); Mousetrap.unbind('alt+left'); Mousetrap.unbind('alt+right'); Mousetrap.unbind('up'); Mousetrap.unbind('down'); Mousetrap.unbind(['ctrl+up', 'command+up']); Mousetrap.unbind(['ctrl+down', 'command+down']); Mousetrap.unbind('z'); 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, togglePlay, removeCutSegment, setCutEnd, setCutStart, seekRel, seekRelPercent, shortStep, deleteSource, jumpSeg, toggleHelp, seekClosestKeyframe, zoomRel, toggleComfortZoom, ]); useEffect(() => { document.ondragover = dragPreventer; document.ondragend = dragPreventer; electron.ipcRenderer.send('renderer-ready'); }, []); useEffect(() => { electron.ipcRenderer.send('setAskBeforeClose', askBeforeClose && isFileOpened); }, [askBeforeClose, isFileOpened]); const extractAllStreams = useCallback(async () => { if (!filePath) return; try { setWorking(i18n.t('Extracting all streams')); await extractStreams({ customOutDir, filePath, streams: mainStreams }); openDirToast({ dirPath: outputDir, text: i18n.t('All streams have been extracted as separate files') }); } catch (err) { errorToast(i18n.t('Failed to extract all streams')); console.error('Failed to extract all streams', err); } finally { setWorking(); } }, [customOutDir, filePath, mainStreams, outputDir]); function onExtractAllStreamsPress() { extractAllStreams(); } const addStreamSourceFile = useCallback(async (path) => { if (externalStreamFiles[path]) return; const { streams } = await getAllStreams(path); const formatData = await getFormatData(path); // console.log('streams', streams); setExternalStreamFiles(old => ({ ...old, [path]: { streams, formatData } })); 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]; // Because Apple is being nazi about the ability to open "copy protected DVD files" const disallowVob = isMasBuild; if (disallowVob && /\.vob$/i.test(firstFile)) { toast.fire({ icon: 'error', text: 'Unfortunately .vob files are not supported in the App Store version of LosslessCut due to Apple restrictions' }); return; } const { newCustomOutDir, cancel } = await assureOutDirAccess(firstFile); if (cancel) return; if (!isFileOpened) { load({ filePath: firstFile, customOutDir: newCustomOutDir }); return; } const { value } = await Swal.fire({ title: i18n.t('You opened a new file. What do you want to do?'), icon: 'question', input: 'radio', inputValue: 'open', showCancelButton: true, customClass: { input: 'swal2-losslesscut-radio' }, inputOptions: { open: i18n.t('Open the file instead of the current one'), add: i18n.t('Include all tracks from the new file'), }, inputValidator: (v) => !v && i18n.t('You need to choose something!'), }); if (value === 'open') { load({ filePath: firstFile, customOutDir: newCustomOutDir }); } else if (value === 'add') { addStreamSourceFile(firstFile); setStreamsSelectorShown(true); } }, [addStreamSourceFile, isFileOpened, load, mergeFiles, assureOutDirAccess]); const onDrop = useCallback(async (ev) => { ev.preventDefault(); const { files } = ev.dataTransfer; const filePaths = Array.from(files).map(f => f.path); focusWindow(); if (filePaths.length === 1 && filePaths[0].toLowerCase().endsWith('.csv')) { loadEdlFile(filePaths[0]); return; } userOpenFiles(filePaths); }, [userOpenFiles, loadEdlFile]); const html5ifyInternal = useCallback(async ({ customOutDir: cod, filePath: fp, speed, hasAudio: ha, hasVideo: hv }) => { const path = getHtml5ifiedPath(cod, fp, speed); let audio; if (ha) { if (speed === 'slowest') audio = 'hq'; else if (speed === 'slow-audio') audio = 'lq-aac'; else if (speed === 'fast-audio') audio = 'copy'; else if (speed === 'fastest-audio') audio = 'lq-flac'; } let video; if (hv) { if (speed === 'slowest') video = 'hq'; else if (['slow-audio', 'slow'].includes(speed)) video = 'lq'; else video = 'copy'; } try { await ffmpegHtml5ify({ filePath: fp, outPath: path, video, audio, onProgress: setCutProgress }); } finally { setCutProgress(); } return path; }, [getHtml5ifiedPath]); const html5ifyAndLoad = useCallback(async (speed) => { if (speed === 'fastest-audio') { const path = await html5ifyInternal({ customOutDir, filePath, speed, hasAudio, hasVideo: false }); load({ filePath, dummyVideoPathRequested: path, customOutDir }); } else { const path = await html5ifyInternal({ customOutDir, filePath, speed, hasAudio, hasVideo }); load({ filePath, html5FriendlyPathRequested: path, customOutDir }); } }, [hasAudio, hasVideo, customOutDir, filePath, html5ifyInternal, load]); const html5ifyCurrentFile = useCallback(async () => { if (!filePath) return; try { setWorking(i18n.t('Converting to supported format')); const speed = await askForHtml5ifySpeed(['fastest', 'fastest-audio', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest']); if (!speed) return; if (speed === 'fastest') { await createDummyVideo(customOutDir, filePath); } else if (['fastest-audio', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest'].includes(speed)) { await html5ifyAndLoad(speed); } } catch (err) { errorToast(i18n.t('Failed to convert file. Try a different conversion')); console.error('Failed to html5ify file', err); } finally { setWorking(); } }, [createDummyVideo, customOutDir, filePath, html5ifyAndLoad]); const onVideoError = useCallback(async () => { const { error } = videoRef.current; if (!error) return; if (!fileUri) return; // Probably MEDIA_ELEMENT_ERROR: Empty src attribute console.error(error.message); const MEDIA_ERR_SRC_NOT_SUPPORTED = 4; if (error.code === MEDIA_ERR_SRC_NOT_SUPPORTED && !dummyVideoPath) { console.log('MEDIA_ERR_SRC_NOT_SUPPORTED - trying to create dummy'); toast.fire({ icon: 'info', text: 'This file is not natively supported. Creating a preview file...' }); if (hasVideo) { await tryCreateDummyVideo(); } else if (hasAudio) { await html5ifyAndLoad('fastest-audio'); } } }, [tryCreateDummyVideo, fileUri, dummyVideoPath, hasVideo, hasAudio, html5ifyAndLoad]); useEffect(() => { function fileOpened(event, filePaths) { userOpenFiles(filePaths); } function closeFile() { if (!isFileOpened) return; // eslint-disable-next-line no-alert if (askBeforeClose && !window.confirm(i18n.t('Are you sure you want to close the current file?'))) return; resetState(); } 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 { if (!isFileOpened) return; const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.csv`, filters: [{ name: i18n.t('CSV files'), extensions: ['csv'] }] }); if (canceled || !fp) return; if (await exists(fp)) { errorToast(i18n.t('File exists, bailing')); return; } await edlStoreSave(fp, cutSegments); } catch (err) { errorToast(i18n.t('Failed to export CSV')); console.error('Failed to export CSV', err); } } async function importEdlFile() { if (!isFileOpened) { toast.fire({ icon: 'info', title: i18n.t('You need to open a media file first') }); return; } const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters: [{ name: i18n.t('CSV files'), extensions: ['csv'] }] }); if (canceled || filePaths.length < 1) return; await loadEdlFile(filePaths[0]); } function openHelp() { toggleHelp(); } function openAbout() { Swal.fire({ icon: 'info', title: 'About LosslessCut', text: `You are running version ${app.getVersion()}`, }); } function openSettings() { toggleSettings(); } async function batchConvertFriendlyFormat() { const title = i18n.t('Select files to batch convert to supported format'); const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], title, message: title }); if (canceled || filePaths.length < 1) return; const failedFiles = []; let i = 0; const speed = await askForHtml5ifySpeed(['fastest-audio', 'fast-audio', 'fast', 'slow', 'slow-audio', 'slowest']); if (!speed) return; try { setWorking(i18n.t('Batch converting to supported format')); setCutProgress(0); // eslint-disable-next-line no-restricted-syntax for (const path of filePaths) { try { // eslint-disable-next-line no-await-in-loop const { newCustomOutDir, cancel } = await assureOutDirAccess(path); if (cancel) { toast.fire({ title: i18n.t('Aborted') }); return; } // eslint-disable-next-line no-await-in-loop await html5ifyInternal({ customOutDir: newCustomOutDir, filePath: path, speed, hasAudio: true, hasVideo: true }); } catch (err2) { console.error('Failed to html5ify', path, err2); failedFiles.push(path); } i += 1; setCutProgress(i / filePaths.length); } if (failedFiles.length > 0) toast.fire({ title: `${i18n.t('Failed to convert files:')} ${failedFiles.join(' ')}`, timer: null, showConfirmButton: true }); } catch (err) { errorToast(i18n.t('Failed to batch convert to supported format')); console.error('Failed to html5ify', err); } finally { setWorking(); setCutProgress(); } } function openSendReportDialog2() { openSendReportDialogWithState(); } electron.ipcRenderer.on('file-opened', fileOpened); electron.ipcRenderer.on('close-file', closeFile); electron.ipcRenderer.on('html5ify', html5ifyCurrentFile); 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); electron.ipcRenderer.on('openHelp', openHelp); electron.ipcRenderer.on('openSettings', openSettings); electron.ipcRenderer.on('openAbout', openAbout); electron.ipcRenderer.on('batchConvertFriendlyFormat', batchConvertFriendlyFormat); electron.ipcRenderer.on('openSendReportDialog', openSendReportDialog2); return () => { electron.ipcRenderer.removeListener('file-opened', fileOpened); electron.ipcRenderer.removeListener('close-file', closeFile); electron.ipcRenderer.removeListener('html5ify', html5ifyCurrentFile); 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); electron.ipcRenderer.removeListener('openHelp', openHelp); electron.ipcRenderer.removeListener('openSettings', openSettings); electron.ipcRenderer.removeListener('openAbout', openAbout); electron.ipcRenderer.removeListener('batchConvertFriendlyFormat', batchConvertFriendlyFormat); electron.ipcRenderer.removeListener('openSendReportDialog', openSendReportDialog2); }; }, [ mergeFiles, outputDir, filePath, isFileOpened, customOutDir, startTimeOffset, html5ifyCurrentFile, createDummyVideo, resetState, extractAllStreams, userOpenFiles, cutSegmentsHistory, openSendReportDialogWithState, loadEdlFile, cutSegments, edlFilePath, askBeforeClose, toggleHelp, toggleSettings, assureOutDirAccess, html5ifyAndLoad, html5ifyInternal, ]); 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); }, [onDrop]); const commonFormatsMap = useMemo(() => fromPairs(commonFormats.map(format => [format, allOutFormats[format]]) .filter(([f]) => f !== detectedFileFormat)), [detectedFileFormat]); const otherFormatsMap = useMemo(() => fromPairs(Object.entries(allOutFormats) .filter(([f]) => ![...commonFormats, detectedFileFormat].includes(f))), [detectedFileFormat]); function renderFormatOptions(map) { return Object.entries(map).map(([f, name]) => ( )); } const renderOutFmt = useCallback((props) => ( // eslint-disable-next-line react/jsx-props-no-spreading ), [commonFormatsMap, detectedFileFormat, fileFormat, otherFormatsMap]); const renderCaptureFormatButton = useCallback((props) => ( ), [captureFormat, toggleCaptureFormat]); const AutoExportToggler = useCallback(() => ( setAutoExportExtraStreams(value === 'extract')} /> ), [autoExportExtraStreams]); const onWheelTunerRequested = useCallback(() => { setSettingsVisible(false); setWheelTunerVisible(true); }, []); const renderSettings = useCallback(() => ( ), [AutoExportToggler, askBeforeClose, autoMerge, autoSaveProjectFile, customOutDir, invertCutSegments, keyframeCut, renderCaptureFormatButton, renderOutFmt, timecodeShowFrames, changeOutDir, onWheelTunerRequested, language, invertTimelineScroll, ffmpegExperimental, setFfmpegExperimental]); useEffect(() => { if (!isStoreBuild) loadMifiLink().then(setMifiLink); }, []); useEffect(() => { // Testing: // if (isDev) load({ filePath: '/Users/mifi/Downloads/inp.MOV', customOutDir }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const VolumeIcon = muted || dummyVideoPath ? FaVolumeMute : FaVolumeUp; 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); }, []); const sideBarWidth = showSideBar && isFileOpened ? 200 : 0; const bottomBarHeight = 96 + ((hasAudio && waveformEnabled) || (hasVideo && thumbnailsEnabled) ? timelineHeight : 0); const thumbnailsSorted = useMemo(() => sortBy(thumbnails, thumbnail => thumbnail.time), [thumbnails]); let timelineMode; if (thumbnailsEnabled) timelineMode = 'thumbnails'; if (waveformEnabled) timelineMode = 'waveform'; const { t } = useTranslation(); // throw new Error('Test'); return (
setStreamsSelectorShown(false)} >
{!isFileOpened && (
{t('DROP FILE(S)')}
Press H for help
I O to set cutpoints
{mifiLink && mifiLink.loadUrl && (