@ -1,16 +1,18 @@
import React , { memo , useEffect , useState , useCallback , useRef } from 'react' ;
import { IoIosHelpCircle , IoIosCamera } from 'react-icons/io' ;
import { FaPlus , FaMinus , FaAngleLeft , FaAngleRight , FaTrashAlt , FaVolumeMute , FaVolumeUp , FaYinYang } from 'react-icons/fa' ;
import { FaPlus , FaMinus , FaAngleLeft , FaAngleRight , FaTrashAlt , FaVolumeMute , FaVolumeUp , FaYinYang , FaFileExport } from 'react-icons/fa' ;
import { MdRotate90DegreesCcw } from 'react-icons/md' ;
import { GiYinYang } from 'react-icons/gi' ;
import { FiScissors } from 'react-icons/fi' ;
import { AnimatePresence , motion } from 'framer-motion' ;
import Swal from 'sweetalert2' ;
import { Popover, Butt on } from 'evergreen-ui' ;
import { SideSheet, Button , Positi on } from 'evergreen-ui' ;
import fromPairs from 'lodash/fromPairs' ;
import clamp from 'lodash/clamp' ;
import clone from 'lodash/clone' ;
import sortBy from 'lodash/sortBy' ;
import flatMap from 'lodash/flatMap' ;
import HelpSheet from './HelpSheet' ;
import TimelineSeg from './TimelineSeg' ;
@ -23,7 +25,7 @@ const isDev = require('electron-is-dev');
const electron = require ( 'electron' ) ; / / e s l i n t - d i s a b l e - l i n e
const Mousetrap = require ( 'mousetrap' ) ;
const Hammer = require ( 'react-hammerjs' ) . default ;
const path = require ( 'path' ) ;
const { dirname } = require ( 'path' ) ;
const trash = require ( 'trash' ) ;
const uuid = require ( 'uuid' ) ;
@ -33,11 +35,11 @@ const { unlink, exists } = require('fs-extra');
const { showMergeDialog , showOpenAndMergeDialog } = require ( './merge/merge' ) ;
const allOutFormats = require ( './outFormats' ) ;
const captureFrame = require ( './capture-frame' ) ;
const ffmpeg = require ( './ffmpeg' ) ;
const { defaultProcessedCodecTypes , getStreamFps } = ffmpeg ;
const { defaultProcessedCodecTypes , getStreamFps , isCuttingStart , isCuttingEnd } = ffmpeg ;
const {
@ -94,10 +96,12 @@ const App = memo(() => {
const [ startTimeOffset , setStartTimeOffset ] = useState ( 0 ) ;
const [ rotationPreviewRequested , setRotationPreviewRequested ] = useState ( false ) ;
const [ filePath , setFilePath ] = useState ( '' ) ;
const [ externalStreamFiles , setExternalStreamFiles ] = useState ( [ ] ) ;
const [ detectedFps , setDetectedFps ] = useState ( ) ;
const [ s treams, setStreams ] = useState ( [ ] ) ;
const [ copyStreamIds , setCopyStreamIds ] = useState ( { } ) ;
const [ mainS treams, setStreams ] = useState ( [ ] ) ;
const [ copyStreamIds ByFile , setCopyStreamIds ByFile ] = useState ( { } ) ;
const [ muted , setMuted ] = useState ( false ) ;
const [ streamsSelectorShown , setStreamsSelectorShown ] = useState ( false ) ;
/ / G l o b a l s t a t e
const [ captureFormat , setCaptureFormat ] = useState ( 'jpeg' ) ;
@ -113,8 +117,15 @@ const App = memo(() => {
const timelineWrapperRef = useRef ( ) ;
function toggleCopyStreamId ( index ) {
setCopyStreamIds ( v => ( { ... v , [ index ] : ! v [ index ] } ) ) ;
function setCopyStreamIdsForPath ( path , cb ) {
setCopyStreamIdsByFile ( ( old ) => {
const oldIds = old [ path ] || { } ;
return ( { ... old , [ path ] : cb ( oldIds ) } ) ;
} ) ;
}
function toggleCopyStreamId ( path , index ) {
setCopyStreamIdsForPath ( path , ( old ) => ( { ... old , [ index ] : ! old [ index ] } ) ) ;
}
function toggleMute ( ) {
@ -166,11 +177,13 @@ const App = memo(() => {
setStartTimeOffset ( 0 ) ;
setRotationPreviewRequested ( false ) ;
setFilePath ( '' ) ; / / S e t t i n g v i d e o s r c = " " p r e v e n t s m e m o r y l e a k i n c h r o m i u m
setExternalStreamFiles ( [ ] ) ;
setDetectedFps ( ) ;
setStreams ( [ ] ) ;
setCopyStreamIds ( { } ) ;
setCopyStreamIds ByFile ( { } ) ;
setMuted ( false ) ;
setInvertCutSegments ( false ) ;
setStreamsSelectorShown ( false ) ;
} , [ ] ) ;
useEffect ( ( ) => ( ) => {
@ -205,6 +218,9 @@ const App = memo(() => {
const currentCutSeg = cutSegments [ currentSegIndex ] ;
const currentApparentCutSeg = apparentCutSegments [ currentSegIndex ] ;
const areWeCutting = apparentCutSegments . length > 1
|| isCuttingStart ( currentApparentCutSeg . start )
|| isCuttingEnd ( currentApparentCutSeg . end , duration ) ;
const inverseCutSegments = ( ( ) => {
if ( haveInvalidSegs ) return undefined ;
@ -305,7 +321,7 @@ const App = memo(() => {
function getOutputDir ( ) {
if ( customOutDir ) return customOutDir ;
if ( filePath ) return path. dirname( filePath ) ;
if ( filePath ) return dirname( filePath ) ;
return undefined ;
}
@ -383,11 +399,29 @@ const App = memo(() => {
const toggleKeyframeCut = ( ) => setKeyframeCut ( val => ! val ) ;
const toggleAutoMerge = ( ) => setAutoMerge ( val => ! val ) ;
const copyAnyAudioTrack = streams . some ( stream => copyStreamIds [ stream . index ] && stream . codec _type === 'audio' ) ;
const isCopyingStreamId = useCallback ( ( path , streamId ) => (
! ! ( copyStreamIdsByFile [ path ] || { } ) [ streamId ]
) , [ copyStreamIdsByFile ] ) ;
const copyAnyAudioTrack = mainStreams . some ( stream => isCopyingStreamId ( filePath , stream . index ) && stream . codec _type === 'audio' ) ;
const copyStreamIds = Object . entries ( copyStreamIdsByFile ) . map ( ( [ path , streamIdsMap ] ) => ( {
path ,
streamIds : Object . keys ( streamIdsMap ) . filter ( index => streamIdsMap [ index ] ) ,
} ) ) ;
const numStreamsToCopy = copyStreamIds
. reduce ( ( acc , { streamIds } ) => acc + streamIds . length , 0 ) ;
const numStreamsTotal = [
... mainStreams ,
... flatMap ( Object . values ( externalStreamFiles ) , ( { streams } ) => streams ) ,
] . length ;
function toggleStripAudio ( ) {
setCopyStreamIds ( ( old ) => {
setCopyStreamIds ForPath ( filePath , ( old ) => {
const newCopyStreamIds = { ... old } ;
streams . forEach ( ( stream ) => {
mainS treams. forEach ( ( stream ) => {
if ( stream . codec _type === 'audio' ) newCopyStreamIds [ stream . index ] = ! copyAnyAudioTrack ;
} ) ;
return newCopyStreamIds ;
@ -478,7 +512,8 @@ const App = memo(() => {
const outFiles = await ffmpeg . cutMultiple ( {
customOutDir ,
filePath ,
format : fileFormat ,
outFormat : fileFormat ,
isOutFormatUserSelected : fileFormat !== detectedFileFormat ,
videoDuration : duration ,
rotation : effectiveRotation ,
copyStreamIds ,
@ -497,13 +532,13 @@ const App = memo(() => {
} ) ;
}
toast . fire ( { timer : 10000 , type : 'success' , title : ` Cu t completed! Output file(s) can be found at: ${ getOutDir ( customOutDir , filePath ) } . You can change the output directory in settings ` } ) ;
toast . fire ( { timer : 10000 , type : 'success' , title : ` Expor t completed! Output file(s) can be found at: ${ getOutDir ( customOutDir , filePath ) } . You can change the output directory in settings ` } ) ;
} catch ( err ) {
console . error ( 'stdout:' , err . stdout ) ;
console . error ( 'stderr:' , err . stderr ) ;
if ( err . code === 1 || err . code === 'ENOENT' ) {
errorToast ( ` Whoops! ffmpeg was unable to cut this video. Try each the following things before attempting to cut again:\ n1. Select a different output format from the ${ fileFormat } button (matroska takes almost everything). \ n2. toggle the button "all" to "ps" ` ) ;
errorToast ( ` 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 ` ) ;
return ;
}
@ -513,12 +548,12 @@ const App = memo(() => {
}
} , [
effectiveRotation , apparentCutSegments , invertCutSegments , inverseCutSegments ,
working , duration , filePath , keyframeCut ,
autoMerge , customOutDir , fileFormat , copyStreamIds, haveInvalidSegs,
working , duration , filePath , keyframeCut , detectedFileFormat ,
autoMerge , customOutDir , fileFormat , haveInvalidSegs, copyStreamIds ,
] ) ;
function showUnsupportedFileMessage ( ) {
toast . fire ( { timer : 10000 , type : 'warning' , title : 'This video is not natively supported' , text : 'This means that there is no audio in the preview and it has low quality. The final cu t operation will however be lossless and contains audio!' } ) ;
toast . fire ( { timer : 10000 , type : 'warning' , title : 'This video is not natively supported' , text : 'This means that there is no audio in the preview and it has low quality. The final expor t operation will however be lossless and contains audio!' } ) ;
}
/ / T O D O u s e f f m p e g t o c a p t u r e f r a m e
@ -586,15 +621,15 @@ const App = memo(() => {
return ;
}
const { streams : streamsNew } = await ffmpeg . getAllStreams ( fp ) ;
console . log ( 'streams' , streamsNew ) ;
setStreams ( streams New ) ;
setCopyStreamIds ( f romPairs( streams New . map ( ( stream ) => [
const { streams } = await ffmpeg . getAllStreams ( fp ) ;
/ / c o n s o l e . l o g ( ' s t r e a m s ' , s t r e a m s N e w ) ;
setStreams ( streams ) ;
setCopyStreamIds ForPath ( f p, ( ) => f romPairs( streams . map ( ( stream ) => [
stream . index , defaultProcessedCodecTypes . includes ( stream . codec _type ) ,
] ) ) ) ;
streams New . find ( ( stream ) => {
streams . find ( ( stream ) => {
const streamFps = getStreamFps ( stream ) ;
if ( streamFps != null ) {
setDetectedFps ( streamFps ) ;
@ -613,7 +648,7 @@ const App = memo(() => {
showUnsupportedFileMessage ( ) ;
} else if (
! ( await checkExistingHtml5FriendlyFile ( fp , 'slow-audio' ) || await checkExistingHtml5FriendlyFile ( fp , 'slow' ) || await checkExistingHtml5FriendlyFile ( fp , 'fast' ) )
&& ! doesPlayerSupportFile ( streams New )
&& ! doesPlayerSupportFile ( streams )
) {
await createDummyVideo ( fp ) ;
}
@ -697,13 +732,61 @@ const App = memo(() => {
extractAllStreams ( ) ;
}
const addStreamSourceFile = useCallback ( async ( path ) => {
if ( externalStreamFiles [ path ] ) return ;
const { streams } = await ffmpeg . getAllStreams ( path ) ;
/ / c o n s o l e . l o g ( ' s t r e a m s ' , s t r e a m s ) ;
setExternalStreamFiles ( old => ( { ... old , [ path ] : { streams } } ) ) ;
setCopyStreamIdsForPath ( path , ( ) => fromPairs ( streams . map ( ( { index } ) => [ index , true ] ) ) ) ;
} , [ externalStreamFiles ] ) ;
const userOpenFiles = useCallback ( async ( filePaths ) => {
if ( filePaths . length < 1 ) return ;
if ( filePaths . length > 1 ) {
showMergeDialog ( filePaths , mergeFiles ) ;
return ;
}
const firstFile = filePaths [ 0 ] ;
if ( ! filePath ) {
load ( firstFile ) ;
return ;
}
const { value } = await Swal . fire ( {
title : 'You opened a new file. What do you want to do?' ,
input : 'radio' ,
showCancelButton : true ,
inputOptions : {
open : 'Open the file instead of the current one. You will lose all work' ,
add : 'Include all tracks from the new file' ,
} ,
inputValidator : ( v ) => ! v && 'You need to choose something!' ,
} ) ;
if ( value === 'open' ) {
load ( firstFile ) ;
} else if ( value === 'add' ) {
addStreamSourceFile ( firstFile ) ;
setStreamsSelectorShown ( true ) ;
}
} , [ addStreamSourceFile , filePath , load , mergeFiles ] ) ;
const onDrop = useCallback ( async ( ev ) => {
ev . preventDefault ( ) ;
const { files } = ev . dataTransfer ;
userOpenFiles ( Array . from ( files ) . map ( f => f . path ) ) ;
} , [ userOpenFiles ] ) ;
useEffect ( ( ) => {
function fileOpened ( event , filePaths ) {
if ( ! filePaths || filePaths . length !== 1 ) return ;
load ( filePaths [ 0 ] ) ;
userOpenFiles ( filePaths ) ;
}
function closeFile ( ) {
/ / e s l i n t - d i s a b l e - n e x t - l i n e n o - a l e r t
if ( ! window . confirm ( 'Are you sure you want to replace the current file? You will lose all work' ) ) return ;
resetState ( ) ;
}
@ -756,7 +839,7 @@ const App = memo(() => {
return ( ) => {
electron . ipcRenderer . removeListener ( 'file-opened' , fileOpened ) ;
electron . ipcRenderer . removeListener ( 'close-file' , 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 ) ;
@ -764,16 +847,14 @@ const App = memo(() => {
} ;
} , [
load , mergeFiles , outputDir , filePath , customOutDir , startTimeOffset , getHtml5ifiedPath ,
createDummyVideo , resetState , extractAllStreams ,
createDummyVideo , resetState , extractAllStreams , userOpenFiles ,
] ) ;
const onDrop = useCallback ( ( ev ) => {
ev . preventDefault ( ) ;
const { files } = ev . dataTransfer ;
if ( files . length < 1 ) return ;
if ( files . length === 1 ) load ( files [ 0 ] . path ) ;
else showMergeDialog ( Array . from ( files ) . map ( f => f . path ) , mergeFiles ) ;
} , [ load , mergeFiles ] ) ;
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 ) ;
@ -822,7 +903,11 @@ const App = memo(() => {
) ;
}
const selectableFormats = [ 'mov' , 'mp4' , 'matroska' ] . filter ( f => f !== detectedFileFormat ) ;
const commonFormats = [ 'mov' , 'mp4' , 'matroska' , 'mp3' , 'ipod' ] ;
const commonFormatsMap = fromPairs ( commonFormats . map ( format => [ format , allOutFormats [ format ] ] )
. filter ( ( [ f ] ) => f !== detectedFileFormat ) ) ;
const otherFormatsMap = fromPairs ( Object . entries ( allOutFormats )
. filter ( ( [ f ] ) => ! [ ... commonFormats , detectedFileFormat ] . includes ( f ) ) ) ;
const durationSafe = duration || 1 ;
const currentTimePos = currentTime !== undefined && ` ${ ( currentTime / durationSafe ) * 100 } % ` ;
@ -834,16 +919,27 @@ const App = memo(() => {
position : 'absolute' , color : 'black' , bottom : 0 , top : 0 , padding : '2px 8px' ,
} ;
function renderFormatOptions ( map ) {
return Object . entries ( map ) . map ( ( [ f , name ] ) => (
< option key = { f } value = { f } > { f } - { name } < / option >
) ) ;
}
function renderOutFmt ( { width } = { } ) {
return (
< select style = { { width } } defaultValue = "" value = { fileFormat } title = "Format of current file" onChange = { withBlur ( e => setFileFormat ( e . target . value ) ) } >
< option key = "" value = "" disabled > Out fmt < / option >
< select style = { { width } } defaultValue = "" value = { fileFormat } title = "Output format" onChange = { withBlur ( e => setFileFormat ( e . target . value ) ) } >
< option key = "disabled1" value = "" disabled > Format < / option >
{ detectedFileFormat && (
< option key = { detectedFileFormat } value = { detectedFileFormat } >
{ detectedFileFormat }
{ detectedFileFormat } - { allOutFormats [ detectedFileFormat ] } ( detected )
< / option >
) }
{ selectableFormats . map ( f => < option key = { f } value = { f } > { f } < / option > ) }
< option key = "disabled2" value = "" disabled > -- - Common formats : -- - < / option >
{ renderFormatOptions ( commonFormatsMap ) }
< option key = "disabled3" value = "" disabled > -- - All formats : -- - < / option >
{ renderFormatOptions ( otherFormatsMap ) }
< / select >
) ;
}
@ -922,14 +1018,14 @@ const App = memo(() => {
< tr >
< td >
D elete audio ?
D iscard audio ?
< / td >
< td >
< button
type = "button"
onClick = { toggleStripAudio }
>
{ ! copyAnyAudioTrack ? 'D elete all audio tracks' : 'Keep audio tracks' }
{ ! copyAnyAudioTrack ? 'D iscard all audio tracks' : 'Keep audio tracks' }
< / button >
< / td >
< / tr >
@ -973,6 +1069,7 @@ const App = memo(() => {
const bottomBarHeight = '6rem' ;
const VolumeIcon = muted ? FaVolumeMute : FaVolumeUp ;
const CutIcon = areWeCutting ? FiScissors : FaFileExport ;
function renderInvertCutButton ( ) {
const KeepOrDiscardIcon = invertCutSegments ? GiYinYang : FaYinYang ;
@ -996,21 +1093,38 @@ const App = memo(() => {
return (
< div >
< div style = { { background : '#6b6b6b' , height : topBarHeight , display : 'flex' , alignItems : 'center' , padding : '0 5px' , justifyContent : 'space-between' } } >
< Popover
content = { (
< SideSheet
containerProps = { { style : { maxWidth : '100%' } } }
position = { Position . LEFT }
isShown = { streamsSelectorShown }
onCloseComplete = { ( ) => setStreamsSelectorShown ( false ) }
>
< StreamsSelector
streams = { streams }
copyStreamIds = { copyStreamIds }
mainFilePath = { filePath }
externalFiles = { externalStreamFiles }
setExternalFiles = { setExternalStreamFiles }
showAddStreamSourceDialog = { showAddStreamSourceDialog }
streams = { mainStreams }
isCopyingStreamId = { isCopyingStreamId }
toggleCopyStreamId = { toggleCopyStreamId }
setCopyStreamIdsForPath = { setCopyStreamIdsForPath }
onExtractAllStreamsPress = { onExtractAllStreamsPress }
/ >
) }
>
< Button height = { 20 } iconBefore = "list" > Tracks < / Button >
< / Popover >
< / SideSheet >
< Button height ={ 20 } iconBefore = "list" onClick = { withBlur ( ( ) => setStreamsSelectorShown ( true ) ) } >
Tracks ( { numStreamsToCopy } / { numStreamsTotal } )
< / Button >
< div style = { { flexGrow : 1 } } / >
< button
type = "button"
onClick = { withBlur ( setOutputDir ) }
title = { customOutDir }
>
{ ` Out path ${ customOutDir ? 'set' : 'unset' } ` }
< / button >
{ renderOutFmt ( { width : 60 } ) }
< button
@ -1032,10 +1146,10 @@ const App = memo(() => {
< button
type = "button"
title = { ` D elete audio? Current: ${ copyAnyAudioTrack ? 'keep audio tracks' : ' delete audio tracks'} ` }
title = { ` D iscard audio? Current: ${ copyAnyAudioTrack ? 'keep audio tracks' : ' Discar d audio tracks'} ` }
onClick = { withBlur ( toggleStripAudio ) }
>
{ copyAnyAudioTrack ? 'Keep audio' : 'D elete audio'}
{ copyAnyAudioTrack ? 'Keep audio' : 'D iscard audio'}
< / button >
< IoIosHelpCircle size = { 24 } role = "button" onClick = { toggleHelp } style = { { verticalAlign : 'middle' , marginLeft : 5 } } / >
@ -1162,14 +1276,6 @@ const App = memo(() => {
< / Hammer >
< div style = { { display : 'flex' , justifyContent : 'center' , alignItems : 'center' } } >
< FaAngleLeft
title = "Set cut start to current position"
style = { { background : segBgColor , borderRadius : 10 , padding : 3 } }
size = { 16 }
onClick = { setCutStart }
role = "button"
/ >
< i
className = "button fa fa-step-backward"
role = "button"
@ -1178,6 +1284,14 @@ const App = memo(() => {
onClick = { ( ) => seekAbs ( 0 ) }
/ >
< FaAngleLeft
title = "Set cut start to current position"
style = { { background : segBgColor , borderRadius : 10 , padding : 3 } }
size = { 16 }
onClick = { setCutStart }
role = "button"
/ >
< div style = { { position : 'relative' } } >
{ renderCutTimeInput ( 'start' ) }
< i
@ -1221,14 +1335,6 @@ const App = memo(() => {
/ >
< / div >
< i
className = "button fa fa-step-forward"
role = "button"
tabIndex = "0"
title = "Jump to end of video"
onClick = { ( ) => seekAbs ( durationSafe ) }
/ >
< FaAngleRight
title = "Set cut end to current position"
style = { { background : segBgColor , borderRadius : 10 , padding : 3 } }
@ -1236,6 +1342,14 @@ const App = memo(() => {
onClick = { setCutEnd }
role = "button"
/ >
< i
className = "button fa fa-step-forward"
role = "button"
tabIndex = "0"
title = "Jump to end of video"
onClick = { ( ) => seekAbs ( durationSafe ) }
/ >
< / div >
< / div >
@ -1288,12 +1402,15 @@ const App = memo(() => {
onClick = { capture }
/ >
< span style = { { background : 'hsl(194, 78%, 47%)' , borderRadius : 5 , padding : '3px 7px' , fontSize : 14 } } >
< FiScissors
style = { { verticalAlign : 'middle' , marginRight : 3 } }
size = { 16 }
< span
style = { { background : 'hsl(194, 78%, 47%)' , borderRadius : 5 , padding : '3px 7px' , fontSize : 14 } }
onClick = { cutClick }
title = { cutSegments . length > 1 ? 'Export all segments' : 'Export selection' }
role = "button"
>
< CutIcon
style = { { verticalAlign : 'middle' , marginRight : 3 } }
size = { 16 }
/ >
Export
< / span >