From c9066c374384ea72cf8ffd71d93601bf9d9519e8 Mon Sep 17 00:00:00 2001 From: Mikael Finstad Date: Tue, 30 Mar 2021 17:54:01 +0700 Subject: [PATCH] test contextIsolation UNFINISHED! todo: move from devdeps to deps import pMap from 'p-map'; import flatMap from 'lodash/flatMap'; import sortBy from 'lodash/sortBy'; import moment from 'moment'; import i18n from 'i18next'; import Timecode from 'smpte-timecode'; test this!!!! const isMasBuild = process.mas; const isWindowsStoreBuild = process.windowsStore; const isStoreBuild = isMasBuild || isWindowsStoreBuild; test file open from command line --- package.json | 2 +- public/electron.js | 25 +- public/i18n-common.js | 7 +- public/preload.js | 80 ++++ public/preload/ffmpeg.js | 603 ++++++++++++++++++++++++ public/preload/util.js | 128 ++++++ public/util.js | 8 + src/App.jsx | 166 ++++--- src/CanvasPlayer.js | 5 +- src/HelpSheet.jsx | 6 +- src/NoFileLoaded.jsx | 3 +- src/capture-frame.js | 7 +- src/components/CopyClipboardButton.jsx | 5 +- src/dialogs.jsx | 6 +- src/edlStore.js | 19 +- src/ffmpeg.js | 611 ++----------------------- src/hooks/useContextMenu.js | 10 +- src/hooks/useFfmpegOperations.js | 23 +- src/hooks/useUserPreferences.js | 6 +- src/i18n.js | 9 +- src/index.jsx | 8 +- src/reporting.jsx | 11 +- src/util.js | 111 ++--- yarn.lock | 7 +- 24 files changed, 1023 insertions(+), 843 deletions(-) create mode 100644 public/preload.js create mode 100644 public/preload/ffmpeg.js create mode 100644 public/preload/util.js create mode 100644 public/util.js diff --git a/package.json b/package.json index 79bb275b..a851cd32 100644 --- a/package.json +++ b/package.json @@ -92,9 +92,9 @@ }, "dependencies": { "cue-parser": "^0.3.0", - "electron-is-dev": "^0.1.2", "electron-store": "^5.1.1", "electron-unhandled": "^3.0.2", + "eventemitter3": "^4.0.7", "execa": "^4.0.0", "ffmpeg-ffprobe-static": "^4.3.1-rc.2", "file-type": "^12.4.0", diff --git a/public/electron.js b/public/electron.js index d4cd0000..6c550d15 100644 --- a/public/electron.js +++ b/public/electron.js @@ -1,18 +1,17 @@ -const electron = require('electron'); // eslint-disable-line -const isDev = require('electron-is-dev'); +const electron = require('electron'); const unhandled = require('electron-unhandled'); const i18n = require('i18next'); +const { join } = require('path'); const menu = require('./menu'); -const configStore = require('./configStore'); const { checkNewVersion } = require('./update-checker'); -require('./i18n'); +const { isDev } = require('./util'); -const { app } = electron; -const { BrowserWindow } = electron; +require('./i18n'); +const { app, BrowserWindow } = electron; unhandled({ showDialog: true, @@ -38,6 +37,8 @@ function createWindow() { nodeIntegration: true, // https://github.com/electron/electron/issues/5107 webSecurity: !isDev, + contextIsolation: true, + preload: isDev ? join(__dirname, 'preload.js') : join(app.getAppPath(), 'preload.js'), // todo test production }, }); @@ -88,8 +89,6 @@ function updateMenu() { // initialization and is ready to create browser windows. // Some APIs can only be used after this event occurs. app.on('ready', async () => { - await configStore.init(); - createWindow(); updateMenu(); @@ -146,13 +145,3 @@ electron.ipcMain.on('setAskBeforeClose', (e, val) => { electron.ipcMain.on('setLanguage', (e, language) => { i18n.changeLanguage(language).then(() => updateMenu()).catch(console.error); }); - -function focusWindow() { - try { - app.focus({ steal: true }); - } catch (err) { - console.error('Failed to focus window', err); - } -} - -module.exports = { focusWindow }; diff --git a/public/i18n-common.js b/public/i18n-common.js index 93ecbc54..99295a4f 100644 --- a/public/i18n-common.js +++ b/public/i18n-common.js @@ -1,10 +1,9 @@ -// const LanguageDetector = window.require('i18next-electron-language-detector'); -const isDev = require('electron-is-dev'); - +// const LanguageDetector = require('i18next-electron-language-detector'); const { app } = require('electron'); - const { join } = require('path'); +const { isDev } = require('./util'); + const getLangPath = (subPath) => (isDev ? join('public', subPath) : join(app.getAppPath(), 'build', subPath)); // Weblate hardcodes different lang codes than electron diff --git a/public/preload.js b/public/preload.js new file mode 100644 index 00000000..30c9a62b --- /dev/null +++ b/public/preload.js @@ -0,0 +1,80 @@ +const { contextBridge, remote, ipcRenderer } = require('electron'); +const { exists, unlink, writeFile, readFile } = require('fs-extra'); +const { extname, parse, sep, join, normalize, resolve, isAbsolute } = require('path'); +const strtok3 = require('strtok3'); +const I18nBackend = require('i18next-fs-backend'); +const execa = require('execa'); + +const { githubLink } = require('./constants'); +const { commonI18nOptions, fallbackLng, loadPath, addPath } = require('./i18n-common'); +const configStore = require('./configStore'); + +const preloadUtil = require('./preload/util'); +const ffmpeg = require('./preload/ffmpeg'); + +async function initPreload() { + await configStore.init(); +} +contextBridge.exposeInMainWorld('init', { preload: initPreload }); + +contextBridge.exposeInMainWorld('constants', { + isDev: !remote.app.isPackaged, + githubLink, +}); + +contextBridge.exposeInMainWorld('util', preloadUtil); +contextBridge.exposeInMainWorld('ffmpeg', ffmpeg); + + +contextBridge.exposeInMainWorld('ipc', { + onMessage: (event, fn) => ipcRenderer.on(event, (e, ...args) => fn(...args)), + send: ipcRenderer.send, +}); + +contextBridge.exposeInMainWorld('fs', { + exists, + unlink, + writeFile, + readFile, +}); // TODO improve? + +contextBridge.exposeInMainWorld('path', { + extname, parse, sep, join, normalize, resolve, isAbsolute, +}); // TODO improve? + +contextBridge.exposeInMainWorld('strtok3', strtok3); // TODO improve + +// TODO improve +let backend; +// Because contextBridge doesn't handle classes +const BackendProxy = { + type: 'backend', + init: (services, backendOptions, i18nextOptions) => { + if (!backend) backend = new I18nBackend(services, backendOptions, i18nextOptions); + }, + read: (language, namespace, callback) => { + backend.read(language, namespace, callback); + }, + // only used in backends acting as cache layer + save: (language, namespace, data, callback) => { + backend.save(language, namespace, data, callback); + }, + create: (languages, namespace, key, fallbackValue, callback) => { + backend.create(languages, namespace, key, fallbackValue, callback); + }, +}; + +contextBridge.exposeInMainWorld('i18n', { + commonI18nOptions, fallbackLng, loadPath, addPath, Backend: BackendProxy, +}); // TODO improve + +contextBridge.exposeInMainWorld('clipboard', { + writeText: remote.clipboard.writeText, +}); // TODO improve + +contextBridge.exposeInMainWorld('configStore', { + get: (key) => configStore.get(key), + set: (key, val) => configStore.set(key, val), +}); + +contextBridge.exposeInMainWorld('execa', { execa }); diff --git a/public/preload/ffmpeg.js b/public/preload/ffmpeg.js new file mode 100644 index 00000000..23863fc2 --- /dev/null +++ b/public/preload/ffmpeg.js @@ -0,0 +1,603 @@ +const pMap = require('p-map'); +const flatMap = require('lodash/flatMap'); +const sortBy = require('lodash/sortBy'); +const moment = require('moment'); +const i18n = require('i18next'); +const Timecode = require('smpte-timecode'); + +const execa = require('execa'); +const { join } = require('path'); +const fileType = require('file-type'); +const readChunk = require('read-chunk'); +const readline = require('readline'); +const os = require('os'); + +const { getOutPath, getExtensionForFormat } = require('./util'); +const { isDev } = require('../util'); + + +function getFfCommandLine(cmd, args) { + const mapArg = arg => (/[^0-9a-zA-Z-_]/.test(arg) ? `'${arg}'` : arg); + return `${cmd} ${args.map(mapArg).join(' ')}`; +} + +function getFfPath(cmd) { + const platform = os.platform(); + + if (platform === 'darwin') { + return isDev ? `ffmpeg-mac/${cmd}` : join(process.resourcesPath, cmd); + } + + const exeName = platform === 'win32' ? `${cmd}.exe` : cmd; + return isDev + ? `node_modules/ffmpeg-ffprobe-static/${exeName}` + : join(process.resourcesPath, `node_modules/ffmpeg-ffprobe-static/${exeName}`); +} + +const getFfmpegPath = () => getFfPath('ffmpeg'); +const getFfprobePath = () => getFfPath('ffprobe'); + +async function runFfprobe(args) { + const ffprobePath = getFfprobePath(); + console.log(getFfCommandLine('ffprobe', args)); + return execa(ffprobePath, args); +} + +function runFfmpeg(args) { + const ffmpegPath = getFfmpegPath(); + console.log(getFfCommandLine('ffmpeg', args)); + return execa(ffmpegPath, args); +} + + +function handleProgress(process, cutDuration, onProgress) { + onProgress(0); + + const rl = readline.createInterface({ input: process.stderr }); + rl.on('line', (line) => { + // console.log('progress', line); + + try { + let match = line.match(/frame=\s*[^\s]+\s+fps=\s*[^\s]+\s+q=\s*[^\s]+\s+(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/); + // Audio only looks like this: "line size= 233422kB time=01:45:50.68 bitrate= 301.1kbits/s speed= 353x " + if (!match) match = line.match(/(?:size|Lsize)=\s*[^\s]+\s+time=\s*([^\s]+)\s+/); + if (!match) return; + + const str = match[1]; + // console.log(str); + const progressTime = Math.max(0, moment.duration(str).asSeconds()); + // console.log(progressTime); + const progress = cutDuration ? progressTime / cutDuration : 0; + onProgress(progress); + } catch (err) { + console.log('Failed to parse ffmpeg progress line', err); + } + }); +} + +function getIntervalAroundTime(time, window) { + return { + from: Math.max(time - window / 2, 0), + to: time + window / 2, + }; +} + +async function readFrames({ filePath, aroundTime, window, stream }) { + let intervalsArgs = []; + if (aroundTime != null) { + const { from, to } = getIntervalAroundTime(aroundTime, window); + intervalsArgs = ['-read_intervals', `${from}%${to}`]; + } + const { stdout } = await runFfprobe(['-v', 'error', ...intervalsArgs, '-show_packets', '-select_streams', stream, '-show_entries', 'packet=pts_time,flags', '-of', 'json', filePath]); + const packetsFiltered = JSON.parse(stdout).packets + .map(p => ({ keyframe: p.flags[0] === 'K', time: parseFloat(p.pts_time, 10) })) + .filter(p => !Number.isNaN(p.time)); + + return sortBy(packetsFiltered, 'time'); +} + +// https://stackoverflow.com/questions/14005110/how-to-split-a-video-using-ffmpeg-so-that-each-chunk-starts-with-a-key-frame +// http://kicherer.org/joomla/index.php/de/blog/42-avcut-frame-accurate-video-cutting-with-only-small-quality-loss +function getSafeCutTime(frames, cutTime, nextMode) { + const sigma = 0.01; + const isCloseTo = (time1, time2) => Math.abs(time1 - time2) < sigma; + + let index; + + if (frames.length < 2) throw new Error(i18n.t('Less than 2 frames found')); + + if (nextMode) { + index = frames.findIndex(f => f.keyframe && f.time >= cutTime - sigma); + if (index === -1) throw new Error(i18n.t('Failed to find next keyframe')); + if (index >= frames.length - 1) throw new Error(i18n.t('We are on the last frame')); + const { time } = frames[index]; + if (isCloseTo(time, cutTime)) { + return undefined; // Already on keyframe, no need to modify cut time + } + return time; + } + + const findReverseIndex = (arr, cb) => { + const ret = [...arr].reverse().findIndex(cb); + if (ret === -1) return -1; + return arr.length - 1 - ret; + }; + + index = findReverseIndex(frames, f => f.time <= cutTime + sigma); + if (index === -1) throw new Error(i18n.t('Failed to find any prev frame')); + if (index === 0) throw new Error(i18n.t('We are on the first frame')); + + if (index === frames.length - 1) { + // Last frame of video, no need to modify cut time + return undefined; + } + if (frames[index + 1].keyframe) { + // Already on frame before keyframe, no need to modify cut time + return undefined; + } + + // We are not on a frame before keyframe, look for preceding keyframe instead + index = findReverseIndex(frames, f => f.keyframe && f.time <= cutTime + sigma); + if (index === -1) throw new Error(i18n.t('Failed to find any prev keyframe')); + if (index === 0) throw new Error(i18n.t('We are on the first keyframe')); + + // Use frame before the found keyframe + return frames[index - 1].time; +} + +function findNearestKeyFrameTime({ frames, time, direction, fps }) { + const sigma = fps ? (1 / fps) : 0.1; + const keyframes = frames.filter(f => f.keyframe && (direction > 0 ? f.time > time + sigma : f.time < time - sigma)); + if (keyframes.length === 0) return undefined; + const nearestFrame = sortBy(keyframes, keyframe => (direction > 0 ? keyframe.time - time : time - keyframe.time))[0]; + if (!nearestFrame) return undefined; + return nearestFrame.time; +} + +async function tryReadChaptersToEdl(filePath) { + try { + const { stdout } = await runFfprobe(['-i', filePath, '-show_chapters', '-print_format', 'json']); + return JSON.parse(stdout).chapters.map((chapter) => { + const start = parseFloat(chapter.start_time); + const end = parseFloat(chapter.end_time); + if (Number.isNaN(start) || Number.isNaN(end)) return undefined; + + const name = chapter.tags && typeof chapter.tags.title === 'string' ? chapter.tags.title : undefined; + + return { + start, + end, + name, + }; + }).filter((it) => it); + } catch (err) { + console.error('Failed to read chapters from file', err); + return []; + } +} + +async function getFormatData(filePath) { + console.log('getFormatData', filePath); + + const { stdout } = await runFfprobe([ + '-of', 'json', '-show_format', '-i', filePath, + ]); + return JSON.parse(stdout).format; +} + + +async function getDuration(filePath) { + return parseFloat((await getFormatData(filePath)).duration); +} + +async function createChaptersFromSegments({ segmentPaths, chapterNames }) { + if (chapterNames) { + try { + const durations = await pMap(segmentPaths, (segmentPath) => getDuration(segmentPath), { concurrency: 3 }); + let timeAt = 0; + return durations.map((duration, i) => { + const ret = { start: timeAt, end: timeAt + duration, name: chapterNames[i] }; + timeAt += duration; + return ret; + }); + } catch (err) { + console.error('Failed to create chapters from segments', err); + } + } + return undefined; +} + +/** + * ffmpeg only supports encoding certain formats, and some of the detected input + * formats are not the same as the names used for encoding. + * Therefore we have to map between detected format and encode format + * See also ffmpeg -formats + */ +function mapFormat(requestedFormat) { + switch (requestedFormat) { + // These two cmds produce identical output, so we assume that encoding "ipod" means encoding m4a + // ffmpeg -i example.aac -c copy OutputFile2.m4a + // ffmpeg -i example.aac -c copy -f ipod OutputFile.m4a + // See also https://github.com/mifi/lossless-cut/issues/28 + case 'm4a': return 'ipod'; + case 'aac': return 'ipod'; + default: return requestedFormat; + } +} + +function determineOutputFormat(ffprobeFormats, ft) { + if (ffprobeFormats.includes(ft.ext)) return ft.ext; + return ffprobeFormats[0] || undefined; +} + +async function getDefaultOutFormat(filePath, formatData) { + const formatsStr = formatData.format_name; + console.log('formats', formatsStr); + const formats = (formatsStr || '').split(','); + + // ffprobe sometimes returns a list of formats, try to be a bit smarter about it. + const bytes = await readChunk(filePath, 0, 4100); + const ft = fileType(bytes) || {}; + console.log(`fileType detected format ${JSON.stringify(ft)}`); + const assumedFormat = determineOutputFormat(formats, ft); + return mapFormat(assumedFormat); +} + +async function getAllStreams(filePath) { + const { stdout } = await runFfprobe([ + '-of', 'json', '-show_entries', 'stream', '-i', filePath, + ]); + + return JSON.parse(stdout); +} + +function getPreferredCodecFormat(codec, type) { + const map = { + mp3: 'mp3', + opus: 'opus', + vorbis: 'ogg', + h264: 'mp4', + hevc: 'mp4', + eac3: 'eac3', + + subrip: 'srt', + + // See mapFormat + m4a: 'ipod', + aac: 'ipod', + + // TODO add more + // TODO allow user to change? + }; + + const format = map[codec]; + if (format) return { ext: getExtensionForFormat(format), format }; + if (type === 'video') return { ext: 'mkv', format: 'matroska' }; + if (type === 'audio') return { ext: 'mka', format: 'matroska' }; + if (type === 'subtitle') return { ext: 'mks', format: 'matroska' }; + if (type === 'data') return { ext: 'bin', format: 'data' }; // https://superuser.com/questions/1243257/save-data-stream + + return undefined; +} + +// https://stackoverflow.com/questions/32922226/extract-every-audio-and-subtitles-from-a-video-with-ffmpeg +async function extractStreams({ filePath, customOutDir, streams }) { + const outStreams = streams.map((s) => ({ + index: s.index, + codec: s.codec_name || s.codec_tag_string || s.codec_type, + type: s.codec_type, + format: getPreferredCodecFormat(s.codec_name, s.codec_type), + })) + .filter(it => it && it.format && it.index != null); + + // console.log(outStreams); + + const streamArgs = flatMap(outStreams, ({ + index, codec, type, format: { format, ext }, + }) => [ + '-map', `0:${index}`, '-c', 'copy', '-f', format, '-y', getOutPath(customOutDir, filePath, `stream-${index}-${type}-${codec}.${ext}`), + ]); + + const ffmpegArgs = [ + '-hide_banner', + + '-i', filePath, + ...streamArgs, + ]; + + // TODO progress + const { stdout } = await runFfmpeg(ffmpegArgs); + console.log(stdout); +} + +async function renderThumbnail(filePath, timestamp) { + const args = [ + '-ss', timestamp, + '-i', filePath, + '-vf', 'scale=-2:200', + '-f', 'image2', + '-vframes', '1', + '-q:v', '10', + '-', + ]; + + const ffmpegPath = await getFfmpegPath(); + const { stdout } = await execa(ffmpegPath, args, { encoding: null }); + + const blob = new Blob([stdout], { type: 'image/jpeg' }); + return URL.createObjectURL(blob); +} + +async function renderThumbnails({ filePath, from, duration, onThumbnail }) { + // Time first render to determine how many to render + const startTime = new Date().getTime() / 1000; + let url = await renderThumbnail(filePath, from); + const endTime = new Date().getTime() / 1000; + onThumbnail({ time: from, url }); + + // Aim for max 3 sec to render all + const numThumbs = Math.floor(Math.min(Math.max(3 / (endTime - startTime), 3), 10)); + // console.log(numThumbs); + + const thumbTimes = Array(numThumbs - 1).fill().map((unused, i) => (from + ((duration * (i + 1)) / (numThumbs)))); + // console.log(thumbTimes); + + await pMap(thumbTimes, async (time) => { + url = await renderThumbnail(filePath, time); + onThumbnail({ time, url }); + }, { concurrency: 2 }); +} + + +async function renderWaveformPng({ filePath, aroundTime, window, color }) { + const { from, to } = getIntervalAroundTime(aroundTime, window); + + const args1 = [ + '-i', filePath, + '-ss', from, + '-t', to - from, + '-c', 'copy', + '-vn', + '-map', 'a:0', + '-f', 'matroska', // mpegts doesn't support vorbis etc + '-', + ]; + + const args2 = [ + '-i', '-', + '-filter_complex', `aformat=channel_layouts=mono,showwavespic=s=640x120:scale=sqrt:colors=${color}`, + '-frames:v', '1', + '-vcodec', 'png', + '-f', 'image2', + '-', + ]; + + console.log(getFfCommandLine('ffmpeg1', args1)); + console.log(getFfCommandLine('ffmpeg2', args2)); + + let ps1; + let ps2; + try { + const ffmpegPath = getFfmpegPath(); + ps1 = execa(ffmpegPath, args1, { encoding: null, buffer: false }); + ps2 = execa(ffmpegPath, args2, { encoding: null }); + ps1.stdout.pipe(ps2.stdin); + + const { stdout } = await ps2; + + const blob = new Blob([stdout], { type: 'image/png' }); + + return { + url: URL.createObjectURL(blob), + from, + aroundTime, + to, + }; + } catch (err) { + if (ps1) ps1.kill(); + if (ps2) ps2.kill(); + throw err; + } +} + +async function extractWaveform({ filePath, outPath }) { + const numSegs = 10; + const duration = 60 * 60; + const maxLen = 0.1; + const segments = Array(numSegs).fill().map((unused, i) => [i * (duration / numSegs), Math.min(duration / numSegs, maxLen)]); + + // https://superuser.com/questions/681885/how-can-i-remove-multiple-segments-from-a-video-using-ffmpeg + let filter = segments.map(([from, len], i) => `[0:a]atrim=start=${from}:end=${from + len},asetpts=PTS-STARTPTS[a${i}]`).join(';'); + filter += ';'; + filter += segments.map((arr, i) => `[a${i}]`).join(''); + filter += `concat=n=${segments.length}:v=0:a=1[out]`; + + console.time('ffmpeg'); + await runFfmpeg([ + '-i', + filePath, + '-filter_complex', + filter, + '-map', + '[out]', + '-f', 'wav', + '-y', + outPath, + ]); + console.timeEnd('ffmpeg'); +} + +// See also capture-frame.js +async function captureFrame({ timestamp, videoPath, outPath }) { + const args = [ + '-ss', timestamp, + '-i', videoPath, + '-vframes', '1', + '-q:v', '3', + '-y', outPath, + ]; + + const ffmpegPath = getFfmpegPath(); + await execa(ffmpegPath, args, { encoding: null }); +} + +// https://www.ffmpeg.org/doxygen/3.2/libavutil_2utils_8c_source.html#l00079 +const defaultProcessedCodecTypes = [ + 'video', + 'audio', + 'subtitle', + 'attachment', +]; + +const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format); + +function isStreamThumbnail(stream) { + return stream && stream.disposition && stream.disposition.attached_pic === 1; +} + +function isAudioSupported(streams) { + const audioStreams = streams.filter(stream => stream.codec_type === 'audio'); + if (audioStreams.length === 0) return true; + // TODO this could be improved + return audioStreams.some(stream => !['ac3'].includes(stream.codec_name)); +} + +function isIphoneHevc(format, streams) { + if (!streams.some((s) => s.codec_name === 'hevc')) return false; + const makeTag = format.tags && format.tags['com.apple.quicktime.make']; + const modelTag = format.tags && format.tags['com.apple.quicktime.model']; + return (makeTag === 'Apple' && modelTag.startsWith('iPhone')); +} + +function getStreamFps(stream) { + const match = typeof stream.avg_frame_rate === 'string' && stream.avg_frame_rate.match(/^([0-9]+)\/([0-9]+)$/); + if (stream.codec_type === 'video' && match) { + const num = parseInt(match[1], 10); + const den = parseInt(match[2], 10); + if (den > 0) return num / den; + } + return undefined; +} + +function createRawFfmpeg({ fps = 25, path, inWidth, inHeight, seekTo, oneFrameOnly, execaOpts, streamIndex, outSize = 320 }) { + // const fps = 25; // TODO + + const aspectRatio = inWidth / inHeight; + + let newWidth; + let newHeight; + if (inWidth > inHeight) { + newWidth = outSize; + newHeight = Math.floor(newWidth / aspectRatio); + } else { + newHeight = outSize; + newWidth = Math.floor(newHeight * aspectRatio); + } + + const args = [ + '-hide_banner', '-loglevel', 'panic', + + '-re', + + '-ss', seekTo, + + '-noautorotate', + + '-i', path, + + '-vf', `fps=${fps},scale=${newWidth}:${newHeight}:flags=lanczos`, + '-map', `0:${streamIndex}`, + '-vcodec', 'rawvideo', + '-pix_fmt', 'rgba', + + ...(oneFrameOnly ? ['-frames:v', '1'] : []), + + '-f', 'image2pipe', + '-', + ]; + + // console.log(args); + + return { + process: execa(getFfmpegPath(), args, execaOpts), + width: newWidth, + height: newHeight, + channels: 4, + }; +} + +function getOneRawFrame({ path, inWidth, inHeight, seekTo, streamIndex, outSize }) { + const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, oneFrameOnly: true, execaOpts: { encoding: null }, outSize }); + return { process, width, height, channels }; +} + +function encodeLiveRawStream({ path, inWidth, inHeight, seekTo, streamIndex }) { + const { process, width, height, channels } = createRawFfmpeg({ path, inWidth, inHeight, seekTo, streamIndex, execaOpts: { encoding: null, buffer: false } }); + + return { + process, + width, + height, + channels, + }; +} + +function parseTimecode(str, frameRate) { + // console.log(str, frameRate); + const t = Timecode(str, frameRate ? parseFloat(frameRate.toFixed(3)) : undefined); + if (!t) return undefined; + const seconds = ((t.hours * 60) + t.minutes) * 60 + t.seconds + (t.frames / t.frameRate); + return Number.isFinite(seconds) ? seconds : undefined; +} + +function getTimecodeFromStreams(streams) { + console.log('Trying to load timecode'); + let foundTimecode; + streams.find((stream) => { + try { + if (stream.tags && stream.tags.timecode) { + const fps = getStreamFps(stream); + foundTimecode = parseTimecode(stream.tags.timecode, fps); + console.log('Loaded timecode', stream.tags.timecode, 'from stream', stream.index); + return true; + } + return undefined; + } catch (err) { + // console.warn('Failed to parse timecode from file streams', err); + return undefined; + } + }); + return foundTimecode; +} + +module.exports = { + getFfCommandLine, + getFfmpegPath, + getFfprobePath, + runFfprobe, + runFfmpeg, + handleProgress, + readFrames, + getSafeCutTime, + findNearestKeyFrameTime, + tryReadChaptersToEdl, + getFormatData, + getDuration, + createChaptersFromSegments, + getDefaultOutFormat, + getAllStreams, + extractStreams, + renderThumbnails, + renderWaveformPng, + extractWaveform, + captureFrame, + defaultProcessedCodecTypes, + isMov, + isStreamThumbnail, + isAudioSupported, + isIphoneHevc, + getStreamFps, + getOneRawFrame, + encodeLiveRawStream, + getTimecodeFromStreams, +}; diff --git a/public/preload/util.js b/public/preload/util.js new file mode 100644 index 00000000..2de9e9f5 --- /dev/null +++ b/public/preload/util.js @@ -0,0 +1,128 @@ +const path = require('path'); +const fs = require('fs-extra'); +const open = require('open'); +const os = require('os'); +const trash = require('trash'); +const mime = require('mime-types'); +const cueParser = require('cue-parser'); +const stringToStream = require('string-to-stream'); + + +const { remote } = require('electron'); + +function focusWindow() { + try { + remote.app.focus({ steal: true }); + } catch (err) { + console.error('Failed to focus window', err); + } +} + +function getOutDir(customOutDir, filePath) { + if (customOutDir) return customOutDir; + if (filePath) return path.dirname(filePath); + return undefined; +} + +function getOutPath(customOutDir, filePath, nameSuffix) { + if (!filePath) return undefined; + const parsed = path.parse(filePath); + + return path.join(getOutDir(customOutDir, filePath), `${parsed.name}-${nameSuffix}`); +} + +async function havePermissionToReadFile(filePath) { + try { + const fd = await fs.open(filePath, 'r'); + try { + await fs.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; +} + +async function checkDirWriteAccess(dirPath) { + try { + await fs.access(dirPath, fs.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; +} + +async function dirExists(dirPath) { + return (await fs.exists(dirPath)) && (await fs.lstat(dirPath)).isDirectory(); +} + +async function transferTimestamps(inPath, outPath, offset = 0) { + try { + const { atime, mtime } = await fs.stat(inPath); + await fs.utimes(outPath, (atime.getTime() / 1000) + offset, (mtime.getTime() / 1000) + offset); + } catch (err) { + console.error('Failed to set output file modified time', err); + } +} + +const getBaseName = (p) => path.basename(p); + +function getExtensionForFormat(format) { + const ext = { + matroska: 'mkv', + ipod: 'm4a', + }[format]; + + return ext || format; +} + +function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) { + return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath); +} + +const platform = os.platform(); +const isWindows = platform === 'win32'; + +// TODO test!!!! +const isMasBuild = process.mas; +const isWindowsStoreBuild = process.windowsStore; +const isStoreBuild = isMasBuild || isWindowsStoreBuild; + + +const getAppVersion = () => remote.app.getVersion(); + +const openExternal = (url) => remote.shell.openExternal(url); + +const buildMenuFromTemplate = (template) => remote.Menu.buildFromTemplate(template); + +module.exports = { + open, + isWindows, + getBaseName, + getOutFileExtension, + getExtensionForFormat, + transferTimestamps, + dirExists, + checkDirWriteAccess, + havePermissionToReadFile, + getOutPath, + getOutDir, + focusWindow, + getAppVersion, + dialog: remote.dialog, + trash, + getExtensionFromMime: mime.extension, + parseCueFile: (p) => cueParser.parse(p), + openExternal, + platform, + buildMenuFromTemplate, + isMasBuild, + isWindowsStoreBuild, + isStoreBuild, + stringToStream, +}; diff --git a/public/util.js b/public/util.js new file mode 100644 index 00000000..96233a14 --- /dev/null +++ b/public/util.js @@ -0,0 +1,8 @@ +const electron = require('electron'); + +// TODO? +const isDev = electron.remote ? !electron.remote.app.isPackaged : !electron.app.isPackaged; + +module.exports = { + isDev, +}; diff --git a/src/App.jsx b/src/App.jsx index 102ce6bb..216a6723 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -17,6 +17,7 @@ import clamp from 'lodash/clamp'; import sortBy from 'lodash/sortBy'; import flatMap from 'lodash/flatMap'; import isEqual from 'lodash/isEqual'; +import EventEmitter from 'eventemitter3'; import useTimelineScroll from './hooks/useTimelineScroll'; import useUserPreferences from './hooks/useUserPreferences'; @@ -41,7 +42,7 @@ import { showMergeDialog, showOpenAndMergeDialog } from './merge/merge'; import allOutFormats from './outFormats'; import { captureFrameFromTag, captureFrameFfmpeg } from './capture-frame'; import { - defaultProcessedCodecTypes, getStreamFps, isCuttingStart, isCuttingEnd, + defaultProcessedCodecTypes, getStreamFps, getDefaultOutFormat, getFormatData, renderThumbnails as ffmpegRenderThumbnails, readFrames, renderWaveformPng, extractStreams, getAllStreams, findNearestKeyFrameTime as ffmpegFindNearestKeyFrameTime, isStreamThumbnail, isAudioSupported, isIphoneHevc, tryReadChaptersToEdl, @@ -53,7 +54,7 @@ import { getOutPath, toast, errorToast, showFfmpegFail, setFileNameTitle, getOutDir, withBlur, checkDirWriteAccess, dirExists, openDirToast, isMasBuild, isStoreBuild, dragPreventer, doesPlayerSupportFile, isDurationValid, isWindows, filenamify, getOutFileExtension, generateSegFileName, defaultOutSegTemplate, - hasDuplicates, havePermissionToReadFile, + hasDuplicates, havePermissionToReadFile, openAbout, isCuttingStart, isCuttingEnd, } from './util'; import { formatDuration } from './util/duration'; import { askForOutDir, askForImportChapters, createNumSegments, createFixedDurationSegments, promptTimeOffset, askForHtml5ifySpeed, askForYouTubeInput, askForFileOpenAction, confirmExtractAllStreamsDialog, cleanupFilesDialog, showDiskFull, showCutFailedDialog, labelSegmentDialog, openYouTubeChaptersDialog } from './dialogs'; @@ -61,19 +62,37 @@ import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { createSegment, createInitialCutSegments, getCleanCutSegments, getSegApparentStart, findSegmentsAtCursor, sortSegments, invertSegments } from './segments'; - 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, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute } = window.require('path'); - -const { dialog, app } = electron.remote; - -const { focusWindow } = electron.remote.require('./electron'); +const { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute } = window.path; + +const { isDev } = window.constants; + +const ipcEmitter = new EventEmitter(); +const reEmitEvent = (event, opts) => ipcEmitter.emit('from-main', { event, opts }); + +[ + 'file-opened', + 'close-file', + 'html5ify', + 'show-merge-dialog', + 'set-start-offset', + 'extract-all-streams', + 'showStreamsSelector', + 'importEdlFile', + 'exportEdlFile', + 'exportEdlYouTube', + 'openHelp', + 'openSettings', + 'openAbout', + 'batchConvertFriendlyFormat', + 'openSendReportDialog', + 'clearSegments', + 'createNumSegments', + 'createFixedDurationSegments', + 'fixInvalidDuration', + 'reorderSegsByStartTime', +].map((ev) => window.ipc.onMessage(ev, (opts) => reEmitEvent(ev, opts))); const ffmpegExtractWindow = 60; @@ -159,7 +178,7 @@ const App = memo(() => { useEffect(() => { const l = language || fallbackLng; i18n.changeLanguage(l).catch(console.error); - electron.ipcRenderer.send('setLanguage', l); + window.ipc.send('setLanguage', l); }, [language]); // Global state @@ -263,7 +282,7 @@ const App = memo(() => { }, [seekRel, detectedFps]); /* useEffect(() => () => { - if (dummyVideoPath) unlink(dummyVideoPath).catch(console.error); + if (dummyVideoPath) window.fs.unlink(dummyVideoPath).catch(console.error); }, [dummyVideoPath]); */ // 360 means we don't modify rotation @@ -874,12 +893,12 @@ const App = memo(() => { try { setWorking(i18n.t('Cleaning up')); - if (deleteTmpFiles && saved.html5FriendlyPath) await trash(saved.html5FriendlyPath).catch(console.error); - if (deleteTmpFiles && saved.dummyVideoPath) await trash(saved.dummyVideoPath).catch(console.error); - if (deleteProjectFile && saved.edlFilePath) await trash(saved.edlFilePath).catch(console.error); + if (deleteTmpFiles && saved.html5FriendlyPath) await window.util.trash(saved.html5FriendlyPath).catch(console.error); + if (deleteTmpFiles && saved.dummyVideoPath) await window.util.trash(saved.dummyVideoPath).catch(console.error); + if (deleteProjectFile && saved.edlFilePath) await window.util.trash(saved.edlFilePath).catch(console.error); // throw new Error('test'); - if (deleteOriginal) await trash(saved.filePath); + if (deleteOriginal) await window.util.trash(saved.filePath); toast.fire({ icon: 'info', title: i18n.t('Cleanup successful') }); } catch (err) { try { @@ -893,10 +912,10 @@ const App = memo(() => { }); if (value) { - if (deleteTmpFiles && saved.html5FriendlyPath) await unlink(saved.html5FriendlyPath).catch(console.error); - if (deleteTmpFiles && saved.dummyVideoPath) await unlink(saved.dummyVideoPath).catch(console.error); - if (deleteProjectFile && saved.edlFilePath) await unlink(saved.edlFilePath).catch(console.error); - if (deleteOriginal) await unlink(saved.filePath); + if (deleteTmpFiles && saved.html5FriendlyPath) await window.fs.unlink(saved.html5FriendlyPath).catch(console.error); + if (deleteTmpFiles && saved.dummyVideoPath) await window.fs.unlink(saved.dummyVideoPath).catch(console.error); + if (deleteProjectFile && saved.edlFilePath) await window.fs.unlink(saved.edlFilePath).catch(console.error); + if (deleteOriginal) await window.fs.unlink(saved.filePath); toast.fire({ icon: 'info', title: i18n.t('Cleanup successful') }); } } catch (err2) { @@ -1202,7 +1221,7 @@ const App = memo(() => { async function checkAndSetExistingHtml5FriendlyFile(speed) { const existing = getHtml5ifiedPath(cod, fp, speed); - const ret = existing && await exists(existing); + const ret = existing && await window.fs.exists(existing); if (ret) { console.log('Found existing supported file', existing); if (speed === 'fastest-audio') { @@ -1284,7 +1303,7 @@ const App = memo(() => { const openedFileEdlPath = getEdlFilePath(fp); - if (await exists(openedFileEdlPath)) { + if (await window.fs.exists(openedFileEdlPath)) { await loadEdlFile(openedFileEdlPath); } else { const edl = await tryReadChaptersToEdl(fp); @@ -1448,11 +1467,11 @@ const App = memo(() => { document.ondragover = dragPreventer; document.ondragend = dragPreventer; - electron.ipcRenderer.send('renderer-ready'); + window.ipc.send('renderer-ready'); }, []); useEffect(() => { - electron.ipcRenderer.send('setAskBeforeClose', askBeforeClose && isFileOpened); + window.ipc.send('setAskBeforeClose', askBeforeClose && isFileOpened); }, [askBeforeClose, isFileOpened]); const extractSingleStream = useCallback(async (index) => { @@ -1554,7 +1573,7 @@ const App = memo(() => { const { files } = ev.dataTransfer; const filePaths = Array.from(files).map(f => f.path); - focusWindow(); + window.util.focusWindow(); if (filePaths.length === 1 && filePaths[0].toLowerCase().endsWith('.csv')) { if (!checkFileOpened()) return; @@ -1652,7 +1671,7 @@ const App = memo(() => { useEffect(() => { function showOpenAndMergeDialog2() { showOpenAndMergeDialog({ - dialog, + dialog: window.util.dialog, defaultPath: outputDir, onMergeClick: mergeFiles, }); @@ -1685,7 +1704,7 @@ const App = memo(() => { filters = [{ name: i18n.t('TXT files'), extensions: [ext, 'txt'] }]; } - const { canceled, filePath: fp } = await dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters }); + const { canceled, filePath: fp } = await window.util.dialog.showSaveDialog({ defaultPath: `${new Date().getTime()}.${ext}`, filters }); if (canceled || !fp) return; console.log('Saving', type, fp); if (type === 'csv') await saveCsv(fp, cutSegments); @@ -1719,22 +1738,14 @@ const App = memo(() => { else if (type === 'pbf') filters = [{ name: i18n.t('PBF files'), extensions: ['pbf'] }]; else if (type === 'mplayer') filters = [{ name: i18n.t('MPlayer EDL'), extensions: ['*'] }]; - const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'], filters }); + const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile'], filters }); if (canceled || filePaths.length < 1) return; await loadEdlFile(filePaths[0], type); } - function openAbout() { - Swal.fire({ - icon: 'info', - title: 'About LosslessCut', - text: `You are running version ${app.getVersion()}`, - }); - } - async function batchConvertFriendlyFormat() { const title = i18n.t('Select files to batch convert to supported format'); - const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], title, message: title }); + const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'], title, message: title }); if (canceled || filePaths.length < 1) return; const failedFiles = []; @@ -1809,49 +1820,34 @@ const App = memo(() => { const openSendReportDialog2 = () => { openSendReportDialogWithState(); }; const closeFile2 = () => { closeFile(); }; - electron.ipcRenderer.on('file-opened', fileOpened); - electron.ipcRenderer.on('close-file', closeFile2); - electron.ipcRenderer.on('html5ify', html5ifyCurrentFile); - electron.ipcRenderer.on('show-merge-dialog', showOpenAndMergeDialog2); - electron.ipcRenderer.on('set-start-offset', setStartOffset); - electron.ipcRenderer.on('extract-all-streams', extractAllStreams); - electron.ipcRenderer.on('showStreamsSelector', showStreamsSelector); - electron.ipcRenderer.on('importEdlFile', importEdlFile); - electron.ipcRenderer.on('exportEdlFile', exportEdlFile); - electron.ipcRenderer.on('exportEdlYouTube', exportEdlYouTube); - electron.ipcRenderer.on('openHelp', toggleHelp); - electron.ipcRenderer.on('openSettings', toggleSettings); - electron.ipcRenderer.on('openAbout', openAbout); - electron.ipcRenderer.on('batchConvertFriendlyFormat', batchConvertFriendlyFormat); - electron.ipcRenderer.on('openSendReportDialog', openSendReportDialog2); - electron.ipcRenderer.on('clearSegments', clearSegments); - electron.ipcRenderer.on('createNumSegments', createNumSegments2); - electron.ipcRenderer.on('createFixedDurationSegments', createFixedDurationSegments2); - electron.ipcRenderer.on('fixInvalidDuration', fixInvalidDuration2); - electron.ipcRenderer.on('reorderSegsByStartTime', reorderSegsByStartTime); - - return () => { - electron.ipcRenderer.removeListener('file-opened', fileOpened); - electron.ipcRenderer.removeListener('close-file', closeFile2); - electron.ipcRenderer.removeListener('html5ify', html5ifyCurrentFile); - electron.ipcRenderer.removeListener('show-merge-dialog', showOpenAndMergeDialog2); - electron.ipcRenderer.removeListener('set-start-offset', setStartOffset); - electron.ipcRenderer.removeListener('extract-all-streams', extractAllStreams); - electron.ipcRenderer.removeListener('showStreamsSelector', showStreamsSelector); - electron.ipcRenderer.removeListener('importEdlFile', importEdlFile); - electron.ipcRenderer.removeListener('exportEdlFile', exportEdlFile); - electron.ipcRenderer.removeListener('exportEdlYouTube', exportEdlYouTube); - electron.ipcRenderer.removeListener('openHelp', toggleHelp); - electron.ipcRenderer.removeListener('openSettings', toggleSettings); - electron.ipcRenderer.removeListener('openAbout', openAbout); - electron.ipcRenderer.removeListener('batchConvertFriendlyFormat', batchConvertFriendlyFormat); - electron.ipcRenderer.removeListener('openSendReportDialog', openSendReportDialog2); - electron.ipcRenderer.removeListener('clearSegments', clearSegments); - electron.ipcRenderer.removeListener('createNumSegments', createNumSegments2); - electron.ipcRenderer.removeListener('createFixedDurationSegments', createFixedDurationSegments2); - electron.ipcRenderer.removeListener('fixInvalidDuration', fixInvalidDuration2); - electron.ipcRenderer.removeListener('reorderSegsByStartTime', reorderSegsByStartTime); - }; + function fromMain({ event, opts }) { + switch (event) { + case 'file-opened': fileOpened(undefined, opts); break; + case 'close-file': closeFile2(undefined, opts); break; + case 'html5ify': html5ifyCurrentFile(undefined, opts); break; + case 'show-merge-dialog': showOpenAndMergeDialog2(undefined, opts); break; + case 'set-start-offset': setStartOffset(undefined, opts); break; + case 'extract-all-streams': extractAllStreams(undefined, opts); break; + case 'showStreamsSelector': showStreamsSelector(undefined, opts); break; + case 'importEdlFile': importEdlFile(undefined, opts); break; + case 'exportEdlFile': exportEdlFile(undefined, opts); break; + case 'exportEdlYouTube': exportEdlYouTube(undefined, opts); break; + case 'openHelp': toggleHelp(undefined, opts); break; + case 'openSettings': toggleSettings(undefined, opts); break; + case 'openAbout': openAbout(undefined, opts); break; + case 'batchConvertFriendlyFormat': batchConvertFriendlyFormat(undefined, opts); break; + case 'openSendReportDialog': openSendReportDialog2(undefined, opts); break; + case 'clearSegments': clearSegments(undefined, opts); break; + case 'createNumSegments': createNumSegments2(undefined, opts); break; + case 'createFixedDurationSegments': createFixedDurationSegments2(undefined, opts); break; + case 'fixInvalidDuration': fixInvalidDuration2(undefined, opts); break; + case 'reorderSegsByStartTime': reorderSegsByStartTime(undefined, opts); break; + default: console.error('Unknown event', event); + } + } + ipcEmitter.on('from-main', fromMain); + + return () => ipcEmitter.removeListener('from-main', fromMain); }, [ mergeFiles, outputDir, filePath, customOutDir, startTimeOffset, html5ifyCurrentFile, createDummyVideo, extractAllStreams, userOpenFiles, openSendReportDialogWithState, @@ -1860,7 +1856,7 @@ const App = memo(() => { ]); async function showAddStreamSourceDialog() { - const { canceled, filePaths } = await dialog.showOpenDialog({ properties: ['openFile'] }); + const { canceled, filePaths } = await window.util.dialog.showOpenDialog({ properties: ['openFile'] }); if (canceled || filePaths.length < 1) return; await addStreamSourceFile(filePaths[0]); } @@ -1969,7 +1965,7 @@ const App = memo(() => { useEffect(() => { // Testing: - // if (isDev) load({ filePath: '/Users/mifi/Downloads/inp.MOV', customOutDir }); + if (isDev) load({ filePath: '/Users/mifi/Downloads/lofoten.mp4', customOutDir }); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); diff --git a/src/CanvasPlayer.js b/src/CanvasPlayer.js index 24e9aeed..a41c8fa1 100644 --- a/src/CanvasPlayer.js +++ b/src/CanvasPlayer.js @@ -1,7 +1,5 @@ import { encodeLiveRawStream, getOneRawFrame } from './ffmpeg'; -// TODO keep everything in electron land? -const strtok3 = window.require('strtok3'); export default ({ path, width: inWidth, height: inHeight, streamIndex }) => { let canvas; @@ -40,7 +38,8 @@ export default ({ path, width: inWidth, height: inHeight, streamIndex }) => { // process.stderr.on('data', data => console.log(data.toString('utf-8'))); - const tokenizer = await strtok3.fromStream(process.stdout); + // TODO keep everything in electron land? + const tokenizer = await window.strtok3.fromStream(process.stdout); const size = width * height * channels; const buf = Buffer.allocUnsafe(size); diff --git a/src/HelpSheet.jsx b/src/HelpSheet.jsx index 135ef0dc..e43ce2ee 100644 --- a/src/HelpSheet.jsx +++ b/src/HelpSheet.jsx @@ -7,9 +7,7 @@ import CopyClipboardButton from './components/CopyClipboardButton'; import { primaryTextColor } from './colors'; import Sheet from './Sheet'; -const electron = window.require('electron'); - -const { githubLink } = electron.remote.require('./constants'); +const { githubLink } = window.constants; const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSeg }) => { const { t } = useTranslation(); @@ -32,7 +30,7 @@ const HelpSheet = memo(({ visible, onTogglePress, ffmpegCommandLog, currentCutSe

{t('For more help and issues, please go to:')}
- electron.shell.openExternal(githubLink)}>{githubLink} + window.util.openExternal(githubLink)}>{githubLink}

{t('Keyboard & mouse shortcuts')}

diff --git a/src/NoFileLoaded.jsx b/src/NoFileLoaded.jsx index 55f01577..d7f519f1 100644 --- a/src/NoFileLoaded.jsx +++ b/src/NoFileLoaded.jsx @@ -7,7 +7,6 @@ import { useTranslation, Trans } from 'react-i18next'; import SetCutpointButton from './components/SetCutpointButton'; import SimpleModeButton from './components/SimpleModeButton'; -const electron = window.require('electron'); const NoFileLoaded = memo(({ topBarHeight, bottomBarHeight, mifiLink, toggleHelp, currentCutSeg, simpleMode, toggleSimpleMode }) => { const { t } = useTranslation(); @@ -33,7 +32,7 @@ const NoFileLoaded = memo(({ topBarHeight, bottomBarHeight, mifiLink, toggleHelp