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