import React , { memo , useEffect , useState , useCallback , useRef , Fragment , useMemo } from 'react' ;
import { FaVolumeMute , FaVolumeUp , FaAngleLeft } 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 PQueue from 'p-queue' ;
import filePathToUrl from 'file-url' ;
import Mousetrap from 'mousetrap' ;
import uuid from 'uuid' ;
import i18n from 'i18next' ;
import { useTranslation } from 'react-i18next' ;
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 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 , renderFrame , mergeFiles as ffmpegMergeFiles , renderThumbnails as ffmpegRenderThumbnails ,
readFrames , renderWaveformPng , html5ifyDummy , cutMultiple , extractStreams , autoMergeSegments , getAllStreams ,
findNearestKeyFrameTime , html5ify as ffmpegHtml5ify ,
} from './ffmpeg' ;
import configStore from './store' ;
import { save as edlStoreSave , load as edlStoreLoad } from './edlStore' ;
import {
getOutPath , formatDuration , toast , errorToast , showFfmpegFail , setFileNameTitle ,
promptTimeOffset , generateColor , getOutDir , withBlur , checkDirWriteAccess , dirExists ,
} from './util' ;
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 ;
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 ( ) ;
} ;
function doesPlayerSupportFile ( streams ) {
// TODO improve, whitelist supported codecs instead
return ! streams . find ( s => [ 'hevc' , 'prores' ] . includes ( s . codec _name ) ) ;
// return true;
}
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' ] ;
const isMasBuild = window . process . mas ;
const isStoreBuild = isMasBuild || window . process . windowsStore ;
// TODO flex
const topBarHeight = 32 ;
const timelineHeight = 36 ;
const zoomMax = 2 * * 14 ;
const videoStyle = { width : '100%' , height : '100%' , objectFit : 'contain' } ;
const queue = new PQueue ( { concurrency : 1 } ) ;
const App = memo ( ( ) => {
// Per project state
const [ framePath , setFramePath ] = useState ( ) ;
const [ waveform , setWaveform ] = useState ( ) ;
const [ html5FriendlyPath , setHtml5FriendlyPath ] = useState ( ) ;
const [ working , setWorking ] = useState ( false ) ;
const [ dummyVideoPath , setDummyVideoPath ] = useState ( false ) ;
const [ playing , setPlaying ] = useState ( false ) ;
const [ playerTime , setPlayerTime ] = useState ( ) ;
const [ duration , setDuration ] = useState ( ) ;
const [ fileFormat , setFileFormat ] = useState ( ) ;
const [ fileFormatData , setFileFormatData ] = useState ( ) ;
const [ detectedFileFormat , setDetectedFileFormat ] = useState ( ) ;
const [ rotation , setRotation ] = useState ( 360 ) ;
const [ cutProgress , setCutProgress ] = useState ( ) ;
const [ startTimeOffset , setStartTimeOffset ] = useState ( 0 ) ;
const [ rotationPreviewRequested , setRotationPreviewRequested ] = useState ( false ) ;
const [ filePath , setFilePath ] = useState ( '' ) ;
const [ externalStreamFiles , setExternalStreamFiles ] = useState ( [ ] ) ;
const [ detectedFps , setDetectedFps ] = useState ( ) ;
const [ mainStreams , 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 ) ;
// 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 ;
// Preferences
const [ captureFormat , setCaptureFormat ] = useState ( configStore . get ( 'captureFormat' ) ) ;
useEffect ( ( ) => configStore . set ( 'captureFormat' , captureFormat ) , [ captureFormat ] ) ;
const [ customOutDir , setCustomOutDir ] = useState ( configStore . get ( 'customOutDir' ) ) ;
useEffect ( ( ) => ( customOutDir === undefined ? configStore . delete ( 'customOutDir' ) : configStore . set ( 'customOutDir' , customOutDir ) ) , [ customOutDir ] ) ;
const [ keyframeCut , setKeyframeCut ] = useState ( configStore . get ( 'keyframeCut' ) ) ;
useEffect ( ( ) => configStore . set ( 'keyframeCut' , keyframeCut ) , [ keyframeCut ] ) ;
const [ autoMerge , setAutoMerge ] = useState ( configStore . get ( 'autoMerge' ) ) ;
useEffect ( ( ) => configStore . set ( 'autoMerge' , autoMerge ) , [ autoMerge ] ) ;
const [ timecodeShowFrames , setTimecodeShowFrames ] = useState ( configStore . get ( 'timecodeShowFrames' ) ) ;
useEffect ( ( ) => configStore . set ( 'timecodeShowFrames' , timecodeShowFrames ) , [ timecodeShowFrames ] ) ;
const [ invertCutSegments , setInvertCutSegments ] = useState ( configStore . get ( 'invertCutSegments' ) ) ;
useEffect ( ( ) => configStore . set ( 'invertCutSegments' , invertCutSegments ) , [ invertCutSegments ] ) ;
const [ autoExportExtraStreams , setAutoExportExtraStreams ] = useState ( configStore . get ( 'autoExportExtraStreams' ) ) ;
useEffect ( ( ) => configStore . set ( 'autoExportExtraStreams' , autoExportExtraStreams ) , [ autoExportExtraStreams ] ) ;
const [ askBeforeClose , setAskBeforeClose ] = useState ( configStore . get ( 'askBeforeClose' ) ) ;
useEffect ( ( ) => configStore . set ( 'askBeforeClose' , askBeforeClose ) , [ askBeforeClose ] ) ;
const [ muted , setMuted ] = useState ( configStore . get ( 'muted' ) ) ;
useEffect ( ( ) => configStore . set ( 'muted' , muted ) , [ muted ] ) ;
const [ autoSaveProjectFile , setAutoSaveProjectFile ] = useState ( configStore . get ( 'autoSaveProjectFile' ) ) ;
useEffect ( ( ) => configStore . set ( 'autoSaveProjectFile' , autoSaveProjectFile ) , [ autoSaveProjectFile ] ) ;
const [ wheelSensitivity , setWheelSensitivity ] = useState ( configStore . get ( 'wheelSensitivity' ) ) ;
useEffect ( ( ) => configStore . set ( 'wheelSensitivity' , wheelSensitivity ) , [ wheelSensitivity ] ) ;
const [ language , setLanguage ] = useState ( configStore . get ( 'language' ) ) ;
useEffect ( ( ) => ( language === undefined ? configStore . delete ( 'language' ) : configStore . set ( 'language' , language ) ) , [ language ] ) ;
useEffect ( ( ) => {
if ( language != null ) i18n . changeLanguage ( language ) . catch ( console . error ) ;
} , [ language ] ) ;
// 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 ) ;
}
}
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 ( { title : i18n . t ( 'Muted preview (note that 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 ] ) ;
const zoomRel = useCallback ( ( rel ) => setZoom ( z => Math . min ( Math . max ( z + rel , 1 ) , zoomMax ) ) , [ ] ) ;
const frameRenderEnabled = ! ! ( rotationPreviewRequested || dummyVideoPath ) ;
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 ;
/ * i f ( k e y f r a m e C u t ) {
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 ( ( ) => {
// 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 ;
/ * i f ( k e y f r a m e C u t ) {
const keyframeAlignedCutTo = getSafeCutTime ( startTime , true ) ;
if ( keyframeAlignedCutTo != null ) startTime = keyframeAlignedCutTo ;
} * /
setCutTime ( 'start' , startTime ) ;
} catch ( err ) {
errorToast ( err . message ) ;
}
}
} , [ setCutTime , currentCutSeg , addCutSegment ] ) ;
const setCutEnd = useCallback ( ( ) => {
try {
const endTime = currentTimeRef . current ;
/ * i f ( k e y f r a m e C u t ) {
const keyframeAlignedCutTo = getSafeCutTime ( endTime , false ) ;
if ( keyframeAlignedCutTo != null ) endTime = keyframeAlignedCutTo ;
} * /
setCutTime ( 'end' , endTime ) ;
} catch ( err ) {
errorToast ( err . message ) ;
}
} , [ setCutTime ] ) ;
const outputDir = getOutDir ( customOutDir , filePath ) ;
const askForOutDir = useCallback ( async ( defaultPath ) => {
const { filePaths } = await dialog . showOpenDialog ( {
properties : [ 'openDirectory' ] ,
title : i18n . t ( 'Where do you want to save output files?' ) ,
message : i18n . t ( 'Where do you want to save output files? Make sure there is enough free space in this folder' ) ,
defaultPath ,
buttonLabel : i18n . t ( 'Select output folder' ) ,
} ) ;
return ( filePaths && filePaths . length === 1 ) ? filePaths [ 0 ] : undefined ;
} , [ ] ) ;
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 ) ;
} , [ askForOutDir , 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 ;
/ * i f ( l a s t S a v e d C u t S e g m e n t s R e f . c u r r e n t
&& 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 ( 'Failed to save CSV' ) ) ;
console . error ( 'Failed to save CSV' , err ) ;
}
}
save ( ) ;
} , [ debouncedCutSegments , edlFilePath , autoSaveProjectFile ] ) ;
// 360 means we don't modify rotation
const isRotationSet = rotation !== 360 ;
const effectiveRotation = isRotationSet ? rotation : undefined ;
useEffect ( ( ) => {
async function throttledRender ( ) {
if ( queue . size < 2 ) {
queue . add ( async ( ) => {
if ( ! frameRenderEnabled ) return ;
if ( playerTime == null || ! filePath ) return ;
try {
const framePathNew = await renderFrame ( playerTime , filePath , effectiveRotation ) ;
setFramePath ( framePathNew ) ;
} catch ( err ) {
console . error ( err ) ;
}
} ) ;
}
await queue . onIdle ( ) ;
}
throttledRender ( ) ;
} , [
filePath , playerTime , frameRenderEnabled , effectiveRotation ,
] ) ;
// Cleanup old
useEffect ( ( ) => ( ) => URL . revokeObjectURL ( framePath ) , [ framePath ] ) ;
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 => setDuration ( e . target . duration ) , [ ] ) ;
const onTimeUpdate = useCallback ( ( e ) => {
const { currentTime } = e . target ;
if ( playerTime === currentTime ) return ;
setRotationPreviewRequested ( false ) ; // Reset this
setPlayerTime ( currentTime ) ;
} , [ playerTime ] ) ;
const increaseRotation = useCallback ( ( ) => {
setRotation ( ( r ) => ( r + 90 ) % 450 ) ;
setRotationPreviewRequested ( true ) ;
} , [ ] ) ;
const assureOutDirAccess = useCallback ( async ( outFilePath ) => {
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 } ;
} , [ askForOutDir , customOutDir ] ) ;
const mergeFiles = useCallback ( async ( { paths , allStreams } ) => {
try {
setWorking ( true ) ;
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 } ) ;
} catch ( err ) {
errorToast ( i18n . t ( 'Failed to merge files. Make sure they are all of the exact same format and codecs' ) ) ;
console . error ( 'Failed to merge files' , err ) ;
} finally {
setWorking ( false ) ;
}
} , [ assureOutDirAccess ] ) ;
const toggleCaptureFormat = useCallback ( ( ) => setCaptureFormat ( f => ( f === 'png' ? 'jpeg' : 'png' ) ) , [ ] ) ;
const toggleKeyframeCut = useCallback ( ( ) => setKeyframeCut ( val => ! val ) , [ ] ) ;
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 copyStreamIds = useMemo ( ( ) => Object . entries ( copyStreamIdsByFile ) . map ( ( [ path , streamIdsMap ] ) => ( {
path ,
streamIds : Object . keys ( streamIdsMap ) . filter ( index => streamIdsMap [ index ] ) ,
} ) ) , [ copyStreamIdsByFile ] ) ;
const numStreamsToCopy = copyStreamIds
. 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 < 2 ) 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 ( ) ;
setFramePath ( ) ;
setHtml5FriendlyPath ( ) ;
setDummyVideoPath ( ) ;
setWorking ( false ) ;
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 ) ;
setRotationPreviewRequested ( false ) ;
setFilePath ( '' ) ; // Setting video src="" prevents memory leak in chromium
setExternalStreamFiles ( [ ] ) ;
setDetectedFps ( ) ;
setMainStreams ( [ ] ) ;
setMainVideoStream ( ) ;
setMainAudioStream ( ) ;
setCopyStreamIdsByFile ( { } ) ;
setStreamsSelectorShown ( false ) ;
setZoom ( 1 ) ;
setShortestFlag ( false ) ;
setZoomWindowStartTime ( 0 ) ;
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 : 10000 , icon : 'warning' , title : i18n . t ( 'This video is not natively supported' ) , text : i18n . t ( 'This means that there is no audio in the preview and it has low quality. The final export operation will however be lossless and contains audio!' ) } ) ;
}
const createDummyVideo = useCallback ( async ( cod , fp ) => {
const html5ifiedDummyPathDummy = getOutPath ( cod , fp , 'html5ified-dummy.mkv' ) ;
await html5ifyDummy ( fp , html5ifiedDummyPathDummy ) ;
setDummyVideoPath ( html5ifiedDummyPathDummy ) ;
setHtml5FriendlyPath ( ) ;
showUnsupportedFileMessage ( ) ;
} , [ ] ) ;
const tryCreateDummyVideo = useCallback ( async ( ) => {
try {
if ( working ) return ;
setWorking ( true ) ;
await createDummyVideo ( customOutDir , filePath ) ;
} catch ( err ) {
console . error ( err ) ;
errorToast ( i18n . t ( 'Failed to playback this file. Try to convert to friendly format from the menu' ) ) ;
} finally {
setWorking ( false ) ;
}
} , [ 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 ) => {
console . error ( err ) ;
} ) ;
} , [ playing , filePath ] ) ;
const onVideoError = useCallback ( ( ) => {
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' ) ;
tryCreateDummyVideo ( ) ;
}
} , [ tryCreateDummyVideo , fileUri , dummyVideoPath ] ) ;
const deleteSource = useCallback ( async ( ) => {
if ( ! filePath ) return ;
// eslint-disable-next-line no-alert
if ( working || ! window . confirm ( ` ${ i18n . t ( 'Are you sure you want to move the source file to trash?' ) } ${ filePath } ` ) ) return ;
try {
setWorking ( true ) ;
await trash ( filePath ) ;
if ( html5FriendlyPath ) await trash ( html5FriendlyPath ) ;
} catch ( err ) {
toast . fire ( { icon : 'error' , title : ` ${ i18n . t ( 'Failed to trash source file:' ) } ${ err . message } ` } ) ;
} finally {
resetState ( ) ;
}
} , [ filePath , html5FriendlyPath , resetState , working ] ) ;
const outSegments = useMemo ( ( ) => ( invertCutSegments ? inverseCutSegments : apparentCutSegments ) ,
[ invertCutSegments , inverseCutSegments , apparentCutSegments ] ) ;
const cutClick = useCallback ( async ( ) => {
if ( working ) {
errorToast ( i18n . t ( 'I\'m busy' ) ) ;
return ;
}
if ( haveInvalidSegs ) {
errorToast ( i18n . t ( 'Start time must be before end time' ) ) ;
return ;
}
if ( numStreamsToCopy === 0 ) {
errorToast ( i18n . t ( 'No tracks to export!' ) ) ;
return ;
}
if ( ! outSegments ) {
errorToast ( i18n . t ( 'No segments to export!' ) ) ;
return ;
}
if ( outSegments . length < 1 ) {
errorToast ( i18n . t ( 'No segments to export' ) ) ;
return ;
}
try {
setWorking ( true ) ;
const outFiles = await cutMultiple ( {
customOutDir ,
filePath ,
outFormat : fileFormat ,
isOutFormatUserSelected : fileFormat !== detectedFileFormat ,
videoDuration : duration ,
rotation : effectiveRotation ,
copyStreamIds ,
keyframeCut ,
segments : outSegments ,
onProgress : setCutProgress ,
appendFfmpegCommandLog ,
shortestFlag ,
} ) ;
if ( outFiles . length > 1 && autoMerge ) {
setCutProgress ( 0 ) ; // TODO implement progress
await autoMergeSegments ( {
customOutDir ,
sourceFile : filePath ,
segmentPaths : outFiles ,
} ) ;
}
if ( exportExtraStreams ) {
try {
await extractStreams ( {
filePath , customOutDir , streams : nonCopiedExtraStreams ,
} ) ;
} catch ( err ) {
console . error ( 'Extra stream export failed' , err ) ;
}
}
const extraStreamsMsg = exportExtraStreams ? ` ${ i18n . t ( 'Extra unprocessable streams were exported to separate files.' ) } ` : '' ;
toast . fire ( { timer : 10000 , icon : 'success' , title : ` ${ i18n . t ( 'Export completed! Go to settings to view the ffmpeg commands that were executed. If output does not look right, try to toggle "Keyframe cut" or try a different output format (e.g. matroska). Output file(s) can be found at:' ) } ${ outputDir } . ${ extraStreamsMsg } ` } ) ;
} catch ( err ) {
console . error ( 'stdout:' , err . stdout ) ;
console . error ( 'stderr:' , err . stderr ) ;
if ( err . exitCode === 1 || err . code === 'ENOENT' ) {
toast . fire ( { icon : 'error' , title : ` Whoops! ffmpeg was unable to export this video. Try one of the following before exporting again: \ n1. Select a different output format from the ${ fileFormat } button (matroska takes almost everything). \ n2. Exclude unnecessary tracks \ n3. Try "Normal cut" and "Keyframe cut" ` , timer : 10000 } ) ;
return ;
}
showFfmpegFail ( err ) ;
} finally {
setWorking ( false ) ;
}
} , [
effectiveRotation , outSegments ,
working , duration , filePath , keyframeCut , detectedFileFormat ,
autoMerge , customOutDir , fileFormat , haveInvalidSegs , copyStreamIds , numStreamsToCopy ,
exportExtraStreams , nonCopiedExtraStreams , outputDir , shortestFlag ,
] ) ;
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 : video . duration } )
: await captureFrameFromTag ( { customOutDir , filePath , video , currentTime , captureFormat } ) ;
toast . fire ( { icon : 'success' , title : ` ${ i18n . t ( 'Screenshot captured to:' ) } ${ outPath } ` } ) ;
} catch ( err ) {
console . error ( err ) ;
errorToast ( i18n . t ( 'Failed to capture frame' ) ) ;
}
} , [ filePath , captureFormat , customOutDir , html5FriendlyPath , dummyVideoPath ] ) ;
const changePlaybackRate = useCallback ( ( dir ) => {
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 ] ) ;
const getHtml5ifiedPath = useCallback ( ( cod , fp , type ) => getOutPath ( cod , fp , ` html5ified- ${ type } .mp4 ` ) , [ ] ) ;
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 EDL file' ) } ( ${ err . message } ) ` ) ;
}
}
} , [ cutSegmentsHistory , setCutSegments ] ) ;
const load = useCallback ( async ( { filePath : fp , customOutDir : cod , html5FriendlyPathRequested } ) => {
console . log ( 'Load' , { fp , cod , html5FriendlyPathRequested } ) ;
if ( working ) {
errorToast ( i18n . t ( 'Tried to load file while busy' ) ) ;
return ;
}
resetState ( ) ;
setWorking ( true ) ;
async function checkAndSetExistingHtml5FriendlyFile ( speed ) {
const existing = getHtml5ifiedPath ( cod , fp , speed ) ;
const ret = existing && await exists ( existing ) ;
if ( ret ) {
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);
setMainStreams ( streams ) ;
setCopyStreamIdsForPath ( fp , ( ) => fromPairs ( streams . map ( ( stream ) => [
stream . index , defaultProcessedCodecTypes . includes ( stream . codec _type ) ,
] ) ) ) ;
const videoStream = streams . find ( stream => stream . codec _type === 'video' && ! [ 'png' ] . includes ( stream . codec _name ) ) ;
const audioStream = streams . find ( stream => stream . codec _type === 'audio' ) ;
setMainVideoStream ( videoStream ) ;
setMainAudioStream ( audioStream ) ;
if ( videoStream ) {
const streamFps = getStreamFps ( videoStream ) ;
if ( streamFps != null ) setDetectedFps ( streamFps ) ;
}
setFileNameTitle ( fp ) ;
setFilePath ( fp ) ;
setFileFormat ( ff ) ;
setDetectedFileFormat ( ff ) ;
setFileFormatData ( fd ) ;
if ( html5FriendlyPathRequested ) {
setHtml5FriendlyPath ( html5FriendlyPathRequested ) ;
showUnsupportedFileMessage ( ) ;
} else if (
! ( await checkAndSetExistingHtml5FriendlyFile ( 'slow-audio' ) || await checkAndSetExistingHtml5FriendlyFile ( 'slow' ) || await checkAndSetExistingHtml5FriendlyFile ( 'fast' ) )
&& ! 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 ( false ) ;
}
} , [ 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 : commandedTime , direction , fps : detectedFps } ) ;
if ( time == null ) return ;
seekAbs ( time ) ;
} , [ commandedTime , 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 ( true ) ;
await extractStreams ( { customOutDir , filePath , streams : mainStreams } ) ;
toast . fire ( { icon : 'success' , title : ` ${ i18n . t ( 'All streams can be found as separate files at:' ) } ${ outputDir } ` } ) ;
} catch ( err ) {
errorToast ( i18n . t ( 'Failed to extract all streams' ) ) ;
console . error ( 'Failed to extract all streams' , err ) ;
} finally {
setWorking ( false ) ;
}
} , [ customOutDir , filePath , mainStreams , outputDir ] ) ;
function onExtractAllStreamsPress ( ) {
extractAllStreams ( ) ;
}
const addStreamSourceFile = useCallback ( async ( path ) => {
if ( externalStreamFiles [ path ] ) return ;
const { streams } = await 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 ] ;
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 ,
inputOptions : {
open : i18n . t ( 'Open the file instead of the current one. You will lose all unsaved work' ) ,
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 ) ;
if ( filePaths . length === 1 && filePaths [ 0 ] . toLowerCase ( ) . endsWith ( '.csv' ) ) {
loadEdlFile ( filePaths [ 0 ] ) ;
return ;
}
userOpenFiles ( filePaths ) ;
} , [ userOpenFiles , loadEdlFile ] ) ;
useEffect ( ( ) => {
function fileOpened ( event , filePaths ) {
userOpenFiles ( filePaths ) ;
}
function closeFile ( ) {
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? You will lose all unsaved work' ) ) ) return ;
resetState ( ) ;
}
async function askForHtml5ifySpeed ( allowedOptions ) {
const availOptions = {
fastest : i18n . t ( 'Fastest: Low playback speed (no audio)' ) ,
fast : i18n . t ( 'Fast: Full quality remux, unlikely to work' ) ,
slow : i18n . t ( 'Slow: Low video quality encode (no audio)' ) ,
'slow-audio' : i18n . t ( 'Slowest: Low video/audio quality encode' ) ,
} ;
const inputOptions = { } ;
allowedOptions . forEach ( ( allowedOption ) => {
inputOptions [ allowedOption ] = availOptions [ allowedOption ] ;
} ) ;
const { value } = await Swal . fire ( {
title : i18n . t ( 'Convert to supported format' ) ,
input : 'radio' ,
inputValue : 'fastest' ,
text : i18n . t ( 'These options will let you convert files to a format that is supported by the player. You can try different options and see which works with your file. Note that this is for preview only. When you run cut operations, they will still be lossless with full quality' ) ,
showCancelButton : true ,
inputOptions ,
inputValidator : ( v ) => ! v && i18n . t ( 'You need to choose something!' ) ,
} ) ;
return value ;
}
async function html5ifyInternal ( { customOutDir : cod , filePath : fp , speed , hasAudio : ha , hasVideo : hv } ) {
const path = getHtml5ifiedPath ( cod , fp , speed ) ;
const encodeVideo = [ 'slow' , 'slow-audio' ] . includes ( speed ) && hv ;
const encodeAudio = speed === 'slow-audio' && ha ;
await ffmpegHtml5ify ( fp , path , encodeVideo , encodeAudio ) ;
return path ;
}
async function html5ify ( ) {
if ( ! filePath ) return ;
try {
setWorking ( true ) ;
const speed = await askForHtml5ifySpeed ( [ 'fastest' , 'fast' , 'slow' , 'slow-audio' ] ) ;
if ( ! speed ) return ;
if ( speed === 'fastest' ) {
await createDummyVideo ( customOutDir , filePath ) ;
} else if ( [ 'fast' , 'slow' , 'slow-audio' ] . includes ( speed ) ) {
const path = await html5ifyInternal ( { customOutDir , filePath , speed , hasAudio , hasVideo } ) ;
load ( { filePath , html5FriendlyPathRequested : path , customOutDir } ) ;
}
} catch ( err ) {
errorToast ( i18n . t ( 'Failed to html5ify file' ) ) ;
console . error ( 'Failed to html5ify file' , err ) ;
} finally {
setWorking ( false ) ;
}
}
function showOpenAndMergeDialog2 ( ) {
showOpenAndMergeDialog ( {
dialog ,
defaultPath : outputDir ,
onMergeClick : mergeFiles ,
} ) ;
}
async function setStartOffset ( ) {
const newStartTimeOffset = await promptTimeOffset (
startTimeOffset !== undefined ? formatDuration ( { seconds : startTimeOffset } ) : undefined ,
) ;
if ( newStartTimeOffset === undefined ) return ;
setStartTimeOffset ( newStartTimeOffset ) ;
}
function undo ( ) {
cutSegmentsHistory . back ( ) ;
}
function redo ( ) {
cutSegmentsHistory . forward ( ) ;
}
async function exportEdlFile ( ) {
try {
const { canceled , filePath : fp } = await dialog . showSaveDialog ( { defaultPath : ` ${ new Date ( ) . getTime ( ) } .csv ` , filters : [ { name : 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 ) 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 friendly 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 ( [ 'fast' , 'slow' , 'slow-audio' ] ) ;
if ( ! speed ) return ;
try {
setWorking ( true ) ;
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 ( ' ' ) } ` } ) ;
} catch ( err ) {
errorToast ( i18n . t ( 'Failed to html5ify' ) ) ;
console . error ( 'Failed to html5ify' , err ) ;
} finally {
setWorking ( false ) ;
setCutProgress ( ) ;
}
}
electron . ipcRenderer . on ( 'file-opened' , fileOpened ) ;
electron . ipcRenderer . on ( 'close-file' , closeFile ) ;
electron . ipcRenderer . on ( 'html5ify' , html5ify ) ;
electron . ipcRenderer . on ( 'show-merge-dialog' , showOpenAndMergeDialog2 ) ;
electron . ipcRenderer . on ( 'set-start-offset' , setStartOffset ) ;
electron . ipcRenderer . on ( 'extract-all-streams' , extractAllStreams ) ;
electron . ipcRenderer . on ( 'undo' , undo ) ;
electron . ipcRenderer . on ( 'redo' , redo ) ;
electron . ipcRenderer . on ( 'importEdlFile' , importEdlFile ) ;
electron . ipcRenderer . on ( 'exportEdlFile' , exportEdlFile ) ;
electron . ipcRenderer . on ( 'openHelp' , openHelp ) ;
electron . ipcRenderer . on ( 'openSettings' , openSettings ) ;
electron . ipcRenderer . on ( 'openAbout' , openAbout ) ;
electron . ipcRenderer . on ( 'batchConvertFriendlyFormat' , batchConvertFriendlyFormat ) ;
return ( ) => {
electron . ipcRenderer . removeListener ( 'file-opened' , fileOpened ) ;
electron . ipcRenderer . removeListener ( 'close-file' , closeFile ) ;
electron . ipcRenderer . removeListener ( 'html5ify' , html5ify ) ;
electron . ipcRenderer . removeListener ( 'show-merge-dialog' , showOpenAndMergeDialog2 ) ;
electron . ipcRenderer . removeListener ( 'set-start-offset' , setStartOffset ) ;
electron . ipcRenderer . removeListener ( 'extract-all-streams' , extractAllStreams ) ;
electron . ipcRenderer . removeListener ( 'undo' , undo ) ;
electron . ipcRenderer . removeListener ( 'redo' , redo ) ;
electron . ipcRenderer . removeListener ( 'importEdlFile' , importEdlFile ) ;
electron . ipcRenderer . removeListener ( 'exportEdlFile' , exportEdlFile ) ;
electron . ipcRenderer . removeListener ( 'openHelp' , openHelp ) ;
electron . ipcRenderer . removeListener ( 'openSettings' , openSettings ) ;
electron . ipcRenderer . removeListener ( 'openAbout' , openAbout ) ;
electron . ipcRenderer . removeListener ( 'batchConvertFriendlyFormat' , batchConvertFriendlyFormat ) ;
} ;
} , [
load , mergeFiles , outputDir , filePath , isFileOpened , customOutDir , startTimeOffset , getHtml5ifiedPath ,
createDummyVideo , resetState , extractAllStreams , userOpenFiles , cutSegmentsHistory ,
loadEdlFile , cutSegments , edlFilePath , askBeforeClose , toggleHelp , toggleSettings , assureOutDirAccess , hasAudio , hasVideo ,
] ) ;
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 ] ) => (
< option key = { f } value = { f } > { f } - { name } < / option >
) ) ;
}
const renderOutFmt = useCallback ( ( props ) => (
// eslint-disable-next-line react/jsx-props-no-spreading
< Select value = { fileFormat || '' } title = { i18n . t ( 'Output format' ) } onChange = { withBlur ( e => setFileFormat ( e . target . value ) ) } { ...props } >
< option key = "disabled1" value = "" disabled > { i18n . t ( 'Format' ) } < / option >
{ detectedFileFormat && (
< option key = { detectedFileFormat } value = { detectedFileFormat } >
{ detectedFileFormat } - { allOutFormats [ detectedFileFormat ] } { i18n . t ( '(detected)' ) }
< / option >
) }
< option key = "disabled2" value = "" disabled > -- - { i18n . t ( 'Common formats:' ) } -- - < / option >
{ renderFormatOptions ( commonFormatsMap ) }
< option key = "disabled3" value = "" disabled > -- - { i18n . t ( 'All formats:' ) } -- - < / option >
{ renderFormatOptions ( otherFormatsMap ) }
< / Select >
) , [ commonFormatsMap , detectedFileFormat , fileFormat , otherFormatsMap ] ) ;
const renderCaptureFormatButton = useCallback ( ( props ) => (
< Button
title = { i18n . t ( 'Capture frame format' ) }
onClick = { withBlur ( toggleCaptureFormat ) }
// eslint-disable-next-line react/jsx-props-no-spreading
{ ... props }
>
{ captureFormat }
< / Button >
) , [ captureFormat , toggleCaptureFormat ] ) ;
const AutoExportToggler = useCallback ( ( ) => (
< SegmentedControl
options = { [ { label : i18n . t ( 'Extract' ) , value : 'extract' } , { label : i18n . t ( 'Discard' ) , value : 'discard' } ] }
value = { autoExportExtraStreams ? 'extract' : 'discard' }
onChange = { value => setAutoExportExtraStreams ( value === 'extract' ) }
/ >
) , [ autoExportExtraStreams ] ) ;
const onWheelTunerRequested = useCallback ( ( ) => {
setSettingsVisible ( false ) ;
setWheelTunerVisible ( true ) ;
} , [ ] ) ;
const renderSettings = useCallback ( ( ) => (
< Settings
changeOutDir = { changeOutDir }
customOutDir = { customOutDir }
autoMerge = { autoMerge }
setAutoMerge = { setAutoMerge }
keyframeCut = { keyframeCut }
setKeyframeCut = { setKeyframeCut }
invertCutSegments = { invertCutSegments }
setInvertCutSegments = { setInvertCutSegments }
autoSaveProjectFile = { autoSaveProjectFile }
setAutoSaveProjectFile = { setAutoSaveProjectFile }
timecodeShowFrames = { timecodeShowFrames }
setTimecodeShowFrames = { setTimecodeShowFrames }
askBeforeClose = { askBeforeClose }
setAskBeforeClose = { setAskBeforeClose }
language = { language }
setLanguage = { setLanguage }
renderOutFmt = { renderOutFmt }
AutoExportToggler = { AutoExportToggler }
renderCaptureFormatButton = { renderCaptureFormatButton }
onWheelTunerRequested = { onWheelTunerRequested }
/ >
) , [ AutoExportToggler , askBeforeClose , autoMerge , autoSaveProjectFile , customOutDir , invertCutSegments , keyframeCut , renderCaptureFormatButton , renderOutFmt , timecodeShowFrames , changeOutDir , onWheelTunerRequested , language ] ) ;
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 ? 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 ( ) ;
return (
< div >
< div className = "no-user-select" style = { { background : controlsBackground , height : topBarHeight , display : 'flex' , alignItems : 'center' , padding : '0 5px' , justifyContent : 'space-between' , flexWrap : 'wrap' } } >
< SideSheet
containerProps = { { style : { maxWidth : '100%' } } }
position = { Position . LEFT }
isShown = { streamsSelectorShown }
onCloseComplete = { ( ) => setStreamsSelectorShown ( false ) }
>
< StreamsSelector
mainFilePath = { filePath }
mainFileFormatData = { fileFormatData }
externalFiles = { externalStreamFiles }
setExternalFiles = { setExternalStreamFiles }
showAddStreamSourceDialog = { showAddStreamSourceDialog }
streams = { mainStreams }
isCopyingStreamId = { isCopyingStreamId }
toggleCopyStreamId = { toggleCopyStreamId }
setCopyStreamIdsForPath = { setCopyStreamIdsForPath }
onExtractAllStreamsPress = { onExtractAllStreamsPress }
areWeCutting = { areWeCutting }
shortestFlag = { shortestFlag }
setShortestFlag = { setShortestFlag }
nonCopiedExtraStreams = { nonCopiedExtraStreams }
AutoExportToggler = { AutoExportToggler }
/ >
< / SideSheet >
< TopMenu
filePath = { filePath }
height = { topBarHeight }
copyAnyAudioTrack = { copyAnyAudioTrack }
toggleStripAudio = { toggleStripAudio }
customOutDir = { customOutDir }
changeOutDir = { changeOutDir }
renderOutFmt = { renderOutFmt }
outSegments = { outSegments }
autoMerge = { autoMerge }
toggleAutoMerge = { toggleAutoMerge }
keyframeCut = { keyframeCut }
toggleKeyframeCut = { toggleKeyframeCut }
toggleHelp = { toggleHelp }
toggleSettings = { toggleSettings }
numStreamsToCopy = { numStreamsToCopy }
numStreamsTotal = { numStreamsTotal }
setStreamsSelectorShown = { setStreamsSelectorShown }
/ >
< / div >
{ ! isFileOpened && (
< div className = "no-user-select" style = { { position : 'fixed' , left : 0 , right : 0 , top : topBarHeight , bottom : bottomBarHeight , border : '2vmin dashed #252525' , color : '#505050' , margin : '5vmin' , display : 'flex' , flexDirection : 'column' , justifyContent : 'center' , alignItems : 'center' , whiteSpace : 'nowrap' } } >
< div style = { { fontSize : '9vmin' , textTransform : 'uppercase' } } > { t ( 'DROP FILE(S)' ) } < / div >
{ mifiLink && mifiLink . loadUrl && (
< div style = { { position : 'relative' , margin : '3vmin' , width : '60vmin' , height : '20vmin' } } >
< iframe src = { mifiLink . loadUrl } title = "iframe" style = { { background : 'rgba(0,0,0,0)' , border : 'none' , pointerEvents : 'none' , width : '100%' , height : '100%' , position : 'absolute' } } / >
{ /* eslint-disable-next-line jsx-a11y/interactive-supports-focus */ }
< div style = { { width : '100%' , height : '100%' , position : 'absolute' , cursor : 'pointer' } } role = "button" onClick = { ( ) => electron . shell . openExternal ( mifiLink . targetUrl ) } / >
< / div >
) }
< / div >
) }
< AnimatePresence >
{ working && (
< div style = { {
position : 'absolute' , zIndex : 1 , bottom : bottomBarHeight , top : topBarHeight , left : 0 , right : 0 , display : 'flex' , justifyContent : 'center' , alignItems : 'center' , pointerEvents : 'none' ,
} }
>
< motion.div
style = { { background : primaryColor , boxShadow : ` ${ primaryColor } 0px 0px 20px 25px ` , borderRadius : 20 , paddingBottom : 15 , color : 'white' , textAlign : 'center' , fontSize : 14 } }
initial = { { opacity : 0 , scale : 0 } }
animate = { { opacity : 1 , scale : 1 } }
exit = { { opacity : 0 , scale : 0 } }
>
< div style = { { width : 150 , height : 150 } } >
< Lottie
options = { { loop : true , autoplay : true , animationData : loadingLottie } }
style = { { width : '170%' , height : '130%' , marginLeft : '-35%' , marginTop : '-29%' } }
/ >
< / div >
< div style = { { marginTop : 10 } } >
{ t ( 'WORKING' ) }
< / div >
{ ( cutProgress != null ) && (
< div style = { { marginTop : 10 } } >
{ ` ${ Math . floor ( cutProgress * 100 ) } % ` }
< / div >
) }
< / motion.div >
< / div >
) }
< / AnimatePresence >
< div className = "no-user-select" style = { { position : 'absolute' , top : topBarHeight , left : 0 , right : sideBarWidth , bottom : bottomBarHeight } } >
{ /* eslint-disable-next-line jsx-a11y/media-has-caption */ }
< video
muted = { muted }
ref = { videoRef }
style = { videoStyle }
src = { fileUri }
onPlay = { onSartPlaying }
onPause = { onStopPlaying }
onDurationChange = { onDurationChange }
onTimeUpdate = { onTimeUpdate }
onError = { onVideoError }
/ >
{ framePath && frameRenderEnabled && (
< img
draggable = { false }
style = { {
width : '100%' , height : '100%' , objectFit : 'contain' , left : 0 , right : 0 , top : 0 , bottom : 0 , position : 'absolute' , background : 'black' ,
} }
src = { framePath }
alt = ""
/ >
) }
< / div >
{ rotationPreviewRequested && (
< div style = { {
position : 'absolute' , top : topBarHeight , marginTop : '1em' , marginRight : '1em' , right : sideBarWidth , color : 'white' ,
} }
>
{ t ( 'Lossless rotation preview' ) }
< / div >
) }
{ isFileOpened && (
< Fragment >
< div
className = "no-user-select"
style = { {
position : 'absolute' , right : sideBarWidth , bottom : bottomBarHeight , color : 'rgba(255,255,255,0.7)' ,
} }
>
< VolumeIcon
title = { t ( 'Mute preview? (will not affect output)' ) }
size = { 30 }
role = "button"
style = { { margin : '0 10px 10px 10px' } }
onClick = { toggleMute }
/ >
{ ! showSideBar && (
< FaAngleLeft
title = { t ( 'Show sidebar' ) }
size = { 30 }
role = "button"
style = { { margin : '0 10px 10px 10px' } }
onClick = { toggleSideBar }
/ >
) }
< / div >
< AnimatePresence >
{ showSideBar && (
< motion.div
style = { { position : 'absolute' , width : sideBarWidth , right : 0 , bottom : bottomBarHeight , top : topBarHeight , background : controlsBackground , color : 'rgba(255,255,255,0.7)' , display : 'flex' , flexDirection : 'column' } }
initial = { { x : sideBarWidth } }
animate = { { x : 0 } }
exit = { { x : sideBarWidth } }
>
< SegmentList
currentSegIndex = { currentSegIndexSafe }
cutSegments = { outSegments }
getFrameCount = { getFrameCount }
formatTimecode = { formatTimecode }
invertCutSegments = { invertCutSegments }
onSegClick = { setCurrentSegIndex }
updateCurrentSegOrder = { updateCurrentSegOrder }
setCurrentSegmentName = { setCurrentSegmentName }
currentCutSeg = { currentCutSeg }
addCutSegment = { addCutSegment }
removeCutSegment = { removeCutSegment }
toggleSideBar = { toggleSideBar }
/ >
< / motion.div >
) }
< / AnimatePresence >
< / Fragment >
) }
< motion.div
className = "no-user-select"
style = { { background : controlsBackground , position : 'absolute' , left : 0 , right : 0 , bottom : 0 , display : 'flex' , flexDirection : 'column' , justifyContent : 'flex-end' } }
animate = { { height : bottomBarHeight } }
>
< Timeline
shouldShowKeyframes = { shouldShowKeyframes }
waveform = { waveform }
shouldShowWaveform = { shouldShowWaveform }
waveformEnabled = { waveformEnabled }
thumbnailsEnabled = { thumbnailsEnabled }
neighbouringFrames = { neighbouringFrames }
thumbnails = { thumbnailsSorted }
getCurrentTime = { getCurrentTime }
startTimeOffset = { startTimeOffset }
playerTime = { playerTime }
commandedTime = { commandedTime }
zoom = { zoom }
seekAbs = { seekAbs }
seekRel = { seekRel }
zoomRel = { zoomRel }
duration = { duration }
durationSafe = { durationSafe }
apparentCutSegments = { apparentCutSegments }
setCurrentSegIndex = { setCurrentSegIndex }
currentSegIndexSafe = { currentSegIndexSafe }
invertCutSegments = { invertCutSegments }
inverseCutSegments = { inverseCutSegments }
mainVideoStream = { mainVideoStream }
formatTimecode = { formatTimecode }
timelineHeight = { timelineHeight }
onZoomWindowStartTimeChange = { setZoomWindowStartTime }
wheelSensitivity = { wheelSensitivity }
/ >
< TimelineControls
seekAbs = { seekAbs }
currentSegIndexSafe = { currentSegIndexSafe }
cutSegments = { cutSegments }
currentCutSeg = { currentCutSeg }
setCutStart = { setCutStart }
setCutEnd = { setCutEnd }
setCurrentSegIndex = { setCurrentSegIndex }
cutStartTimeManual = { cutStartTimeManual }
setCutStartTimeManual = { setCutStartTimeManual }
cutEndTimeManual = { cutEndTimeManual }
setCutEndTimeManual = { setCutEndTimeManual }
duration = { durationSafe }
jumpCutEnd = { jumpCutEnd }
jumpCutStart = { jumpCutStart }
startTimeOffset = { startTimeOffset }
setCutTime = { setCutTime }
currentApparentCutSeg = { currentApparentCutSeg }
playing = { playing }
shortStep = { shortStep }
seekClosestKeyframe = { seekClosestKeyframe }
togglePlay = { togglePlay }
setTimelineMode = { setTimelineMode }
timelineMode = { timelineMode }
hasAudio = { hasAudio }
hasVideo = { hasVideo }
keyframesEnabled = { keyframesEnabled }
setKeyframesEnabled = { setKeyframesEnabled }
/ >
< div style = { { display : 'flex' , justifyContent : 'space-between' } } >
< LeftMenu
zoom = { zoom }
setZoom = { setZoom }
invertCutSegments = { invertCutSegments }
setInvertCutSegments = { setInvertCutSegments }
toggleComfortZoom = { toggleComfortZoom }
/ >
< RightMenu
isRotationSet = { isRotationSet }
rotation = { rotation }
areWeCutting = { areWeCutting }
increaseRotation = { increaseRotation }
deleteSource = { deleteSource }
renderCaptureFormatButton = { renderCaptureFormatButton }
capture = { capture }
cutClick = { cutClick }
multipleCutSegments = { cutSegments . length > 1 }
/ >
< / div >
< / motion.div >
< HelpSheet
visible = { helpVisible }
onTogglePress = { toggleHelp }
ffmpegCommandLog = { ffmpegCommandLog }
/ >
< SettingsSheet
visible = { settingsVisible }
onTogglePress = { toggleSettings }
renderSettings = { renderSettings }
/ >
{ wheelTunerVisible && (
< div style = { { display : 'flex' , alignItems : 'center' , background : 'white' , color : 'black' , padding : 10 , margin : 10 , borderRadius : 10 , width : '100%' , maxWidth : 500 , position : 'fixed' , left : 0 , bottom : bottomBarHeight , zIndex : 10 } } >
{ t ( 'Scroll sensitivity' ) }
< input style = { { flexGrow : 1 } } type = "range" min = "0" max = "1000" step = "1" value = { wheelSensitivity * 1000 } onChange = { e => setWheelSensitivity ( e . target . value / 1000 ) } / >
< Button height = { 20 } intent = "success" onClick = { ( ) => setWheelTunerVisible ( false ) } > { t ( 'Done' ) } < / Button >
< / div >
) }
< / div >
) ;
} ) ;
export default App ;