mirror of https://github.com/mifi/lossless-cut
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
381 lines
13 KiB
JavaScript
381 lines
13 KiB
JavaScript
import Swal from 'sweetalert2';
|
|
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';
|
|
|
|
const { dirname, parse: parsePath, join, extname, isAbsolute, resolve } = 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 const swalToastOptions = {
|
|
toast: true,
|
|
position: 'top',
|
|
showConfirmButton: false,
|
|
showCloseButton: true,
|
|
timer: 5000,
|
|
timerProgressBar: true,
|
|
didOpen: (self) => {
|
|
self.addEventListener('mouseenter', Swal.stopTimer);
|
|
self.addEventListener('mouseleave', Swal.resumeTimer);
|
|
},
|
|
};
|
|
|
|
export const toast = Swal.mixin(swalToastOptions);
|
|
|
|
export const errorToast = (text) => toast.fire({
|
|
icon: 'error',
|
|
text,
|
|
});
|
|
|
|
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!
|
|
|
|
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;
|
|
}
|