import i18n from 'i18next' ;
import lodashTemplate from 'lodash/template' ;
import pMap from 'p-map' ;
import ky from 'ky' ;
import prettyBytes from 'pretty-bytes' ;
import isDev from './isDev' ;
import Swal , { toast } from './swal' ;
const { dirname , parse : parsePath , join , extname , isAbsolute , resolve , basename } = window . require ( 'path' ) ;
const fsExtra = window . require ( 'fs-extra' ) ;
const { stat } = window . require ( 'fs/promises' ) ;
const os = window . require ( 'os' ) ;
const { ipcRenderer } = window . require ( 'electron' ) ;
const remote = window . require ( '@electron/remote' ) ;
const { readdir , unlink } = fsExtra ;
const trashFile = async ( path ) => ipcRenderer . invoke ( 'tryTrashItem' , path ) ;
export function getFileDir ( filePath ) {
return filePath ? dirname ( filePath ) : undefined ;
}
export function getOutDir ( customOutDir , filePath ) {
if ( customOutDir ) return customOutDir ;
if ( filePath ) return getFileDir ( filePath ) ;
return undefined ;
}
function getFileBaseName ( filePath ) {
if ( ! filePath ) return undefined ;
const parsed = parsePath ( filePath ) ;
return parsed . name ;
}
export function getOutPath ( { customOutDir , filePath , fileName } ) {
if ( ! filePath ) return undefined ;
return join ( getOutDir ( customOutDir , filePath ) , fileName ) ;
}
export const getSuffixedFileName = ( filePath , nameSuffix ) => ` ${ getFileBaseName ( filePath ) } - ${ nameSuffix } ` ;
export function getSuffixedOutPath ( { customOutDir , filePath , nameSuffix } ) {
if ( ! filePath ) return undefined ;
return getOutPath ( { customOutDir , filePath , fileName : getSuffixedFileName ( filePath , nameSuffix ) } ) ;
}
export async function havePermissionToReadFile ( filePath ) {
try {
const fd = await fsExtra . open ( filePath , 'r' ) ;
try {
await fsExtra . close ( fd ) ;
} catch ( err ) {
console . error ( 'Failed to close fd' , err ) ;
}
} catch ( err ) {
if ( [ 'EPERM' , 'EACCES' ] . includes ( err . code ) ) return false ;
console . error ( err ) ;
}
return true ;
}
export async function checkDirWriteAccess ( dirPath ) {
try {
await fsExtra . access ( dirPath , fsExtra . constants . W _OK ) ;
} catch ( err ) {
if ( err . code === 'EPERM' ) return false ; // Thrown on Mac (MAS build) when user has not yet allowed access
if ( err . code === 'EACCES' ) return false ; // Thrown on Linux when user doesn't have access to output dir
console . error ( err ) ;
}
return true ;
}
export async function pathExists ( pathIn ) {
return fsExtra . pathExists ( pathIn ) ;
}
export async function getPathReadAccessError ( pathIn ) {
try {
await fsExtra . access ( pathIn , fsExtra . constants . R _OK ) ;
return undefined ;
} catch ( err ) {
return err . code ;
}
}
export async function dirExists ( dirPath ) {
return ( await pathExists ( dirPath ) ) && ( await fsExtra . lstat ( dirPath ) ) . isDirectory ( ) ;
}
export async function transferTimestamps ( inPath , outPath , offset = 0 ) {
try {
const { atime , mtime } = await stat ( inPath ) ;
await fsExtra . utimes ( outPath , ( atime . getTime ( ) / 1000 ) + offset , ( mtime . getTime ( ) / 1000 ) + offset ) ;
} catch ( err ) {
console . error ( 'Failed to set output file modified time' , err ) ;
}
}
export function handleError ( arg1 , arg2 ) {
console . error ( 'handleError' , arg1 , arg2 ) ;
let msg ;
let errorMsg ;
if ( typeof arg1 === 'string' ) msg = arg1 ;
else if ( typeof arg2 === 'string' ) msg = arg2 ;
if ( arg1 instanceof Error ) errorMsg = arg1 . message ;
if ( arg2 instanceof Error ) errorMsg = arg2 . message ;
toast . fire ( {
icon : 'error' ,
title : msg || i18n . t ( 'An error has occurred.' ) ,
text : errorMsg ? errorMsg . substring ( 0 , 300 ) : undefined ,
} ) ;
}
export function filenamify ( name ) {
return name . replace ( /[^0-9a-zA-Z_\-.]/g , '_' ) ;
}
export function withBlur ( cb ) {
return ( e ) => {
cb ( e ) ;
e . target . blur ( ) ;
} ;
}
export function dragPreventer ( ev ) {
ev . preventDefault ( ) ;
}
export const isMasBuild = window . process . mas ;
export const isWindowsStoreBuild = window . process . windowsStore ;
export const isStoreBuild = isMasBuild || isWindowsStoreBuild ;
export const platform = os . platform ( ) ;
export const arch = os . arch ( ) ;
export const isWindows = platform === 'win32' ;
export const isMac = platform === 'darwin' ;
export function getExtensionForFormat ( format ) {
const ext = {
matroska : 'mkv' ,
ipod : 'm4a' ,
adts : 'aac' ,
} [ format ] ;
return ext || format ;
}
export function getOutFileExtension ( { isCustomFormatSelected , outFormat , filePath } ) {
if ( ! isCustomFormatSelected ) {
const ext = extname ( filePath ) ;
// QuickTime is quirky about the file extension of mov files (has to be .mov)
// https://github.com/mifi/lossless-cut/issues/1075#issuecomment-1072084286
const hasMovIncorrectExtension = outFormat === 'mov' && ext . toLowerCase ( ) !== '.mov' ;
// OK, just keep the current extension. Because most players will not care about the extension
if ( ! hasMovIncorrectExtension ) return extname ( filePath ) ;
}
// user is changing format, must update extension too
return ` . ${ getExtensionForFormat ( outFormat ) } ` ;
}
// This is used as a fallback and so it has to always generate unique file names
// eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}' ;
export function generateSegFileName ( { template , inputFileNameWithoutExt , segSuffix , ext , segNum , segLabel , cutFrom , cutTo , tags } ) {
const compiled = lodashTemplate ( template ) ;
const data = {
FILENAME : inputFileNameWithoutExt ,
SEG _SUFFIX : segSuffix ,
EXT : ext ,
SEG _NUM : segNum ,
SEG _LABEL : segLabel ,
CUT _FROM : cutFrom ,
CUT _TO : cutTo ,
SEG _TAGS : {
// allow both original case and uppercase
... tags ,
... Object . fromEntries ( Object . entries ( tags ) . map ( ( [ key , value ] ) => [ ` ${ key . toLocaleUpperCase ( 'en-US' ) } ` , value ] ) ) ,
} ,
} ;
return compiled ( data ) ;
}
export const hasDuplicates = ( arr ) => new Set ( arr ) . size !== arr . length ;
// Need to resolve relative paths from the command line https://github.com/mifi/lossless-cut/issues/639
export const resolvePathIfNeeded = ( inPath ) => ( isAbsolute ( inPath ) ? inPath : resolve ( inPath ) ) ;
export const html5ifiedPrefix = 'html5ified-' ;
export const html5dummySuffix = 'dummy' ;
export async function findExistingHtml5FriendlyFile ( fp , cod ) {
// The order is the priority we will search:
const suffixes = [ 'slowest' , 'slow-audio' , 'slow' , 'fast-audio-remux' , 'fast-audio' , 'fast' , 'fastest-audio' , 'fastest-audio-remux' , html5dummySuffix ] ;
const prefix = getSuffixedFileName ( fp , html5ifiedPrefix ) ;
const outDir = getOutDir ( cod , fp ) ;
const dirEntries = await readdir ( outDir ) ;
const html5ifiedDirEntries = dirEntries . filter ( ( entry ) => entry . startsWith ( prefix ) ) ;
let matches = [ ] ;
suffixes . forEach ( ( suffix ) => {
const entryWithSuffix = html5ifiedDirEntries . find ( ( entry ) => new RegExp ( ` ${ suffix } \\ ..* $ ` ) . test ( entry . replace ( prefix , '' ) ) ) ;
if ( entryWithSuffix ) matches = [ ... matches , { entry : entryWithSuffix , suffix } ] ;
} ) ;
const nonMatches = html5ifiedDirEntries . filter ( ( entry ) => ! matches . some ( ( m ) => m . entry === entry ) ) . map ( ( entry ) => ( { entry } ) ) ;
// Allow for non-suffix matches too, e.g. user has a custom html5ified- file but with none of the suffixes above (but last priority)
matches = [ ... matches , ... nonMatches ] ;
// console.log(matches);
if ( matches . length < 1 ) return undefined ;
const { suffix , entry } = matches [ 0 ] ;
return {
path : join ( outDir , entry ) ,
usingDummyVideo : [ 'fastest-audio' , 'fastest-audio-remux' , html5dummySuffix ] . includes ( suffix ) ,
} ;
}
export function getHtml5ifiedPath ( cod , fp , type ) {
// See also inside ffmpegHtml5ify
const ext = ( isMac && [ 'slowest' , 'slow' , 'slow-audio' ] . includes ( type ) ) ? 'mp4' : 'mkv' ;
return getSuffixedOutPath ( { customOutDir : cod , filePath : fp , nameSuffix : ` ${ html5ifiedPrefix } ${ type } . ${ ext } ` } ) ;
}
export async function deleteFiles ( { toDelete , paths : { previewFilePath , sourceFilePath , projectFilePath } } ) {
const failedToTrashFiles = [ ] ;
if ( toDelete . tmpFiles && previewFilePath ) {
try {
await trashFile ( previewFilePath ) ;
} catch ( err ) {
console . error ( err ) ;
failedToTrashFiles . push ( previewFilePath ) ;
}
}
if ( toDelete . projectFile && projectFilePath ) {
try {
// throw new Error('test');
await trashFile ( projectFilePath ) ;
} catch ( err ) {
console . error ( err ) ;
failedToTrashFiles . push ( projectFilePath ) ;
}
}
if ( toDelete . sourceFile ) {
try {
await trashFile ( sourceFilePath ) ;
} catch ( err ) {
console . error ( err ) ;
failedToTrashFiles . push ( sourceFilePath ) ;
}
}
if ( failedToTrashFiles . length === 0 ) return ; // All good!
// todo allow bypassing trash altogether? https://github.com/mifi/lossless-cut/discussions/1425
const { value } = await Swal . fire ( {
icon : 'warning' ,
text : i18n . t ( 'Unable to move file to trash. Do you want to permanently delete it?' ) ,
confirmButtonText : i18n . t ( 'Permanently delete' ) ,
showCancelButton : true ,
} ) ;
if ( value ) {
await pMap ( failedToTrashFiles , async ( path ) => unlink ( path ) , { concurrency : 1 } ) ;
}
}
export const deleteDispositionValue = 'llc_disposition_remove' ;
export const mirrorTransform = 'matrix(-1, 0, 0, 1, 0, 0)' ;
// I *think* Windows will throw error with code ENOENT if ffprobe/ffmpeg fails (execa), but other OS'es will return this error code if a file is not found, so it would be wrong to attribute it to exec failure.
// see https://github.com/mifi/lossless-cut/issues/451
export const isExecaFailure = ( err ) => err . exitCode === 1 || ( isWindows && err . code === 'ENOENT' ) ;
// A bit hacky but it works, unless someone has a file called "No space left on device" ( ͡° ͜ʖ ͡°)
export const isOutOfSpaceError = ( err ) => (
err && isExecaFailure ( err )
&& typeof err . stderr === 'string' && err . stderr . includes ( 'No space left on device' )
) ;
export async function checkAppPath ( ) {
try {
const forceCheck = false ;
// const forceCheck = isDev;
// this code is purposefully obfuscated to try to detect the most basic cloned app submissions to the MS Store
if ( ! isWindowsStoreBuild && ! forceCheck ) return ;
// eslint-disable-next-line no-useless-concat, one-var, one-var-declaration-per-line
const mf = 'mi' + 'fi.no' , llc = 'Los' + 'slessC' + 'ut' ;
const appPath = isDev ? 'C:\\Program Files\\WindowsApps\\37672NoveltyStudio.MediaConverter_9.0.6.0_x64__vjhnv588cyf84' : remote . app . getAppPath ( ) ;
const pathMatch = appPath . replace ( /\\/g , '/' ) . match ( /Windows ?Apps\/([^/]+)/ ) ; // find the first component after WindowsApps
// example pathMatch: 37672NoveltyStudio.MediaConverter_9.0.6.0_x64__vjhnv588cyf84
if ( ! pathMatch ) {
console . warn ( 'Unknown path match' , appPath ) ;
return ;
}
const pathSeg = pathMatch [ 1 ] ;
if ( pathSeg . startsWith ( ` 57275 ${ mf } . ${ llc } _ ` ) ) return ;
// this will report the path and may return a msg
const response = await ky ( ` https://losslesscut-analytics.mifi.no/ ${ pathSeg . length } / ${ btoa ( pathSeg ) } ` ) . json ( ) ;
if ( response . invalid ) toast . fire ( { timer : 60000 , icon : 'error' , title : response . title , text : response . text } ) ;
} catch ( err ) {
if ( isDev ) console . warn ( err . message ) ;
}
}
// https://stackoverflow.com/a/2450976/6519037
export function shuffleArray ( arrayIn ) {
const array = [ ... arrayIn ] ;
let currentIndex = array . length ;
let randomIndex ;
// While there remain elements to shuffle...
while ( currentIndex !== 0 ) {
// Pick a remaining element...
randomIndex = Math . floor ( Math . random ( ) * currentIndex ) ;
currentIndex -= 1 ;
// And swap it with the current element.
[ array [ currentIndex ] , array [ randomIndex ] ] = [
array [ randomIndex ] , array [ currentIndex ] ] ;
}
return array ;
}
export const getNumDigits = ( value ) => Math . floor ( value > 0 ? Math . log10 ( value ) : 0 ) + 1 ;
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping
export function escapeRegExp ( string ) {
return string . replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' ) ; // $& means the whole matched string
}
export const readFileSize = async ( path ) => ( await stat ( path ) ) . size ;
export const readFileSizes = ( paths ) => pMap ( paths , async ( path ) => readFileSize ( path ) , { concurrency : 5 } ) ;
export function checkFileSizes ( inputSize , outputSize ) {
const diff = Math . abs ( outputSize - inputSize ) ;
const relDiff = diff / inputSize ;
const maxDiffPercent = 5 ;
const sourceFilesTotalSize = prettyBytes ( inputSize ) ;
const outputFileTotalSize = prettyBytes ( outputSize ) ;
if ( relDiff > maxDiffPercent / 100 ) return i18n . t ( 'The size of the merged output file ({{outputFileTotalSize}}) differs from the total size of source files ({{sourceFilesTotalSize}}) by more than {{maxDiffPercent}}%. This could indicate that there was a problem during the merge.' , { maxDiffPercent , sourceFilesTotalSize , outputFileTotalSize } ) ;
return undefined ;
}
function setDocumentExtraTitle ( extra ) {
const baseTitle = 'LosslessCut' ;
if ( extra != null ) document . title = ` ${ baseTitle } - ${ extra } ` ;
else document . title = baseTitle ;
}
export function setDocumentTitle ( { filePath , working , cutProgress } ) {
const parts = [ ] ;
if ( filePath ) parts . push ( basename ( filePath ) ) ;
if ( working ) {
parts . push ( '-' , working ) ;
if ( cutProgress != null ) parts . push ( ` ${ ( cutProgress * 100 ) . toFixed ( 1 ) } % ` ) ;
}
setDocumentExtraTitle ( parts . length > 0 ? parts . join ( ' ' ) : undefined ) ;
}