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
context-isolation-test
Mikael Finstad 2021-03-30 17:54:01 +07:00
rodzic 5e1700ef04
commit c9066c3743
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 25AB36E3E81CBC26
24 zmienionych plików z 1019 dodań i 839 usunięć

Wyświetl plik

@ -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",

Wyświetl plik

@ -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');
const { isDev } = require('./util');
require('./i18n');
const { app } = electron;
const { BrowserWindow } = electron;
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 };

Wyświetl plik

@ -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

80
public/preload.js 100644
Wyświetl plik

@ -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 });

Wyświetl plik

@ -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,
};

Wyświetl plik

@ -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,
};

8
public/util.js 100644
Wyświetl plik

@ -0,0 +1,8 @@
const electron = require('electron');
// TODO?
const isDev = electron.remote ? !electron.remote.app.isPackaged : !electron.app.isPackaged;
module.exports = {
isDev,
};

Wyświetl plik

@ -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 { extname, parse: parsePath, sep: pathSep, join: pathJoin, normalize: pathNormalize, resolve: pathResolve, isAbsolute: pathIsAbsolute } = window.path;
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 { isDev } = window.constants;
const { dialog, app } = electron.remote;
const ipcEmitter = new EventEmitter();
const reEmitEvent = (event, opts) => ipcEmitter.emit('from-main', { event, opts });
const { focusWindow } = electron.remote.require('./electron');
[
'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);
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 () => {
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);
};
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
}, []);

Wyświetl plik

@ -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);

Wyświetl plik

@ -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
<p style={{ fontWeight: 'bold' }}>
{t('For more help and issues, please go to:')}<br />
<span style={{ color: primaryTextColor, cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(githubLink)}>{githubLink}</span>
<span style={{ color: primaryTextColor, cursor: 'pointer' }} role="button" onClick={() => window.util.openExternal(githubLink)}>{githubLink}</span>
</p>
<h1>{t('Keyboard & mouse shortcuts')}</h1>

Wyświetl plik

@ -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
<div style={{ position: 'relative', margin: '3vmin', width: '60vmin', height: '20vmin' }}>
<iframe src={mifiLink.loadUrl} title="iframe" style={{ background: 'rgba(0,0,0,0)', border: 'none', pointerEvents: 'none', width: '100%', height: '100%', position: 'absolute' }} />
{/* eslint-disable-next-line jsx-a11y/interactive-supports-focus */}
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={() => electron.shell.openExternal(mifiLink.targetUrl)} />
<div style={{ width: '100%', height: '100%', position: 'absolute', cursor: 'pointer' }} role="button" onClick={() => window.util.openExternal(mifiLink.targetUrl)} />
</div>
)}
</div>

Wyświetl plik

@ -5,9 +5,6 @@ import { formatDuration } from './util/duration';
import { captureFrame as ffmpegCaptureFrame } from './ffmpeg';
const fs = window.require('fs-extra');
const mime = window.require('mime-types');
function getFrameFromVideo(video, format) {
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
@ -33,11 +30,11 @@ export async function captureFrameFfmpeg({ customOutDir, filePath, currentTime,
export async function captureFrameFromTag({ customOutDir, filePath, currentTime, captureFormat, video, enableTransferTimestamps }) {
const buf = getFrameFromVideo(video, captureFormat);
const ext = mime.extension(buf.mimetype);
const ext = window.util.getExtensionFromMime(buf.mimetype);
const time = formatDuration({ seconds: currentTime, fileNameFriendly: true });
const outPath = getOutPath(customOutDir, filePath, `${time}.${ext}`);
await fs.writeFile(outPath, buf);
await window.fs.writeFile(outPath, buf);
if (enableTransferTimestamps) await transferTimestamps(filePath, outPath, currentTime);
return outPath;

Wyświetl plik

@ -3,16 +3,13 @@ import { FaClipboard } from 'react-icons/fa';
import { useTranslation } from 'react-i18next';
import { motion, useAnimation } from 'framer-motion';
const electron = window.require('electron');
const { clipboard } = electron;
const CopyClipboardButton = memo(({ text, style }) => {
const { t } = useTranslation();
const animation = useAnimation();
const onClick = useCallback(() => {
clipboard.writeText(text);
window.clipboard.writeText(text);
animation.start({
scale: [1, 2, 1],
transition: { duration: 0.3 },

Wyświetl plik

@ -8,10 +8,6 @@ import { parseDuration } from './util/duration';
import { parseYouTube } from './edlFormats';
import CopyClipboardButton from './components/CopyClipboardButton';
const electron = window.require('electron'); // eslint-disable-line
const { dialog } = electron.remote;
const ReactSwal = withReactContent(Swal);
export async function promptTimeOffset(inputValue) {
@ -86,7 +82,7 @@ export async function askForYouTubeInput() {
}
export async function askForOutDir(defaultPath) {
const { filePaths } = await dialog.showOpenDialog({
const { filePaths } = await window.dialog.showOpenDialog({
properties: ['openDirectory', 'createDirectory'],
defaultPath,
title: i18n.t('Where do you want to save output files?'),

Wyświetl plik

@ -4,35 +4,32 @@ import pify from 'pify';
import { parseCuesheet, parseXmeml, parseCsv, parsePbf, parseMplayerEdl } from './edlFormats';
import { formatDuration } from './util/duration';
const fs = window.require('fs-extra');
const cueParser = window.require('cue-parser');
const csvStringifyAsync = pify(csvStringify);
export async function loadCsv(path) {
return parseCsv(await fs.readFile(path, 'utf-8'));
return parseCsv(await window.fs.readFile(path, 'utf-8'));
}
export async function loadXmeml(path) {
return parseXmeml(await fs.readFile(path, 'utf-8'));
return parseXmeml(await window.fs.readFile(path, 'utf-8'));
}
export async function loadPbf(path) {
return parsePbf(await fs.readFile(path, 'utf-8'));
return parsePbf(await window.fs.readFile(path, 'utf-8'));
}
export async function loadMplayerEdl(path) {
return parseMplayerEdl(await fs.readFile(path, 'utf-8'));
return parseMplayerEdl(await window.fs.readFile(path, 'utf-8'));
}
export async function loadCue(path) {
return parseCuesheet(cueParser.parse(path));
return parseCuesheet(window.util.parseCueFile(path));
}
export async function saveCsv(path, cutSegments) {
const rows = cutSegments.map(({ start, end, name }) => [start, end, name]);
const str = await csvStringifyAsync(rows);
await fs.writeFile(path, str);
await window.fs.writeFile(path, str);
}
const formatDurationStr = (duration) => (duration != null ? formatDuration({ seconds: duration }) : '');
@ -41,10 +38,10 @@ const mapSegments = (segments) => segments.map(({ start, end, name }) => [format
export async function saveCsvHuman(path, cutSegments) {
const str = await csvStringifyAsync(mapSegments(cutSegments));
await fs.writeFile(path, str);
await window.fs.writeFile(path, str);
}
export async function saveTsv(path, cutSegments) {
const str = await csvStringifyAsync(mapSegments(cutSegments), { delimiter: '\t' });
await fs.writeFile(path, str);
await window.fs.writeFile(path, str);
}

Wyświetl plik

@ -1,580 +1,33 @@
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';
import { getOutPath, isDurationValid, getExtensionForFormat } from './util';
const execa = window.require('execa');
const { join } = window.require('path');
const fileType = window.require('file-type');
const readChunk = window.require('read-chunk');
const readline = window.require('readline');
const isDev = window.require('electron-is-dev');
const os = window.require('os');
export 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(window.process.resourcesPath, cmd);
}
const exeName = platform === 'win32' ? `${cmd}.exe` : cmd;
return isDev
? `node_modules/ffmpeg-ffprobe-static/${exeName}`
: join(window.process.resourcesPath, `node_modules/ffmpeg-ffprobe-static/${exeName}`);
}
export const getFfmpegPath = () => getFfPath('ffmpeg');
export const getFfprobePath = () => getFfPath('ffprobe');
export async function runFfprobe(args) {
const ffprobePath = getFfprobePath();
console.log(getFfCommandLine('ffprobe', args));
return execa(ffprobePath, args);
}
export function runFfmpeg(args) {
const ffmpegPath = getFfmpegPath();
console.log(getFfCommandLine('ffmpeg', args));
return execa(ffmpegPath, args);
}
export 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);
}
});
}
export function isCuttingStart(cutFrom) {
return cutFrom > 0;
}
export function isCuttingEnd(cutTo, duration) {
if (!isDurationValid(duration)) return true;
return cutTo < duration;
}
function getIntervalAroundTime(time, window) {
return {
from: Math.max(time - window / 2, 0),
to: time + window / 2,
};
}
export 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
export 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;
}
export 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;
}
export 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 [];
}
}
export async function getFormatData(filePath) {
console.log('getFormatData', filePath);
const { stdout } = await runFfprobe([
'-of', 'json', '-show_format', '-i', filePath,
]);
return JSON.parse(stdout).format;
}
export async function getDuration(filePath) {
return parseFloat((await getFormatData(filePath)).duration);
}
export 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;
}
export 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);
}
export 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
export 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);
}
export 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 });
}
export 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;
}
}
export 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
export 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
export const defaultProcessedCodecTypes = [
'video',
'audio',
'subtitle',
'attachment',
];
export const isMov = (format) => ['ismv', 'ipod', 'mp4', 'mov'].includes(format);
export function isStreamThumbnail(stream) {
return stream && stream.disposition && stream.disposition.attached_pic === 1;
}
export 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));
}
export 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'));
}
export 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,
};
}
export 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 };
}
export 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;
}
export 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;
}
// TODO
export const {
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,
} = window.ffmpeg;

Wyświetl plik

@ -1,24 +1,22 @@
import { useCallback, useEffect, useMemo } from 'react';
// TODO pull out?
const { remote } = window.require('electron');
const { Menu } = remote;
// https://github.com/transflow/use-electron-context-menu
// TODO pull out?
const { buildMenuFromTemplate } = window.util;
export default function useContextMenu(
ref,
template,
options = {},
) {
const menu = useMemo(() => Menu.buildFromTemplate(template), [template]);
const menu = useMemo(() => buildMenuFromTemplate(template), [template]);
const { x, y, onContext, onClose } = options;
useEffect(() => {
function handleContext(e) {
menu.popup({
window: remote.getCurrentWindow(),
x,
y,
callback: onClose,

Wyświetl plik

@ -4,14 +4,15 @@ import flatMapDeep from 'lodash/flatMapDeep';
import sum from 'lodash/sum';
import pMap from 'p-map';
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir } from '../util';
import { isCuttingStart, isCuttingEnd, handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
import { getOutPath, transferTimestamps, getOutFileExtension, getOutDir, isCuttingStart, isCuttingEnd } from '../util';
import { handleProgress, getFfCommandLine, getFfmpegPath, getDuration, runFfmpeg, createChaptersFromSegments } from '../ffmpeg';
const execa = window.require('execa');
const os = window.require('os');
const { join } = window.require('path');
const fs = window.require('fs-extra');
const stringToStream = window.require('string-to-stream');
const { join } = window.path;
const { writeFile, unlink } = window.path;
// TODO improve
const { execa } = window.execa;
const { stringToStream } = window.util;
async function writeChaptersFfmetadata(outDir, chapters) {
if (!chapters) return undefined;
@ -23,7 +24,7 @@ async function writeChaptersFfmetadata(outDir, chapters) {
return `[CHAPTER]\nTIMEBASE=1/1000\nSTART=${Math.floor(start * 1000)}\nEND=${Math.floor(end * 1000)}\ntitle=${nameOut}`;
}).join('\n\n');
// console.log(ffmetadata);
await fs.writeFile(path, ffmetadata);
await writeFile(path, ffmetadata);
return path;
}
@ -244,7 +245,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const { stdout } = await process;
console.log(stdout);
} finally {
if (ffmetadataPath) await fs.unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
if (ffmetadataPath) await unlink(ffmetadataPath).catch((err) => console.error('Failed to delete', ffmetadataPath, err));
}
await optionalTransferTimestamps(paths[0], outPath);
@ -259,7 +260,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
const chapters = await createChaptersFromSegments({ segmentPaths, chapterNames });
await mergeFiles({ paths: segmentPaths, outDir, outPath, outFormat, allStreams: true, ffmpegExperimental, onProgress, preserveMovData, movFastStart, chapters, preserveMetadataOnMerge });
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => fs.unlink(path), { concurrency: 5 });
if (autoDeleteMergedSegments) await pMap(segmentPaths, path => unlink(path), { concurrency: 5 });
}, [filePath, mergeFiles]);
const html5ify = useCallback(async ({ filePath: specificFilePath, outPath, video, audio, onProgress }) => {
@ -268,7 +269,7 @@ function useFfmpegOperations({ filePath, enableTransferTimestamps }) {
let videoArgs;
let audioArgs;
const isMac = os.platform() === 'darwin';
const isMac = window.util.platform === 'darwin';
switch (video) {
case 'hq': {

Wyświetl plik

@ -3,10 +3,8 @@ import i18n from 'i18next';
import { errorToast } from '../util';
const electron = window.require('electron'); // eslint-disable-line
const isDev = window.require('electron-is-dev');
const configStore = electron.remote.require('./configStore');
const { isDev } = window.constants;
const { configStore } = window;
export default () => {
const firstUpdateRef = useRef(true);

Wyświetl plik

@ -1,17 +1,14 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
const Backend = window.require('i18next-fs-backend');
const electron = window.require('electron'); // eslint-disable-line
const { commonI18nOptions, fallbackLng, loadPath, addPath } = electron.remote.require('./i18n-common');
const { commonI18nOptions, fallbackLng, loadPath, addPath, Backend } = window.i18n;
export { fallbackLng };
// https://github.com/i18next/i18next/issues/869
i18n
.use(Backend)
// TODO
// .use(Backend)
// detect user language
// learn more: https://github.com/i18next/i18next-browser-languageDetector
// TODO disabled for now because translations need more reviewing https://github.com/mifi/lossless-cut/issues/346

Wyświetl plik

@ -8,8 +8,8 @@ import './fonts.css';
import './main.css';
const electron = window.require('electron');
console.log('Version', window.util.getAppVersion());
console.log('Version', electron.remote.app.getVersion());
ReactDOM.render(<ErrorBoundary><Suspense fallback={<div />}><App /></Suspense></ErrorBoundary>, document.getElementById('root'));
window.init.preload().then(() => {
ReactDOM.render(<ErrorBoundary><Suspense fallback={<div />}><App /></Suspense></ErrorBoundary>, document.getElementById('root'));
}).catch(console.error);

Wyświetl plik

@ -7,20 +7,17 @@ import { Trans } from 'react-i18next';
import CopyClipboardButton from './components/CopyClipboardButton';
import { isStoreBuild, isMasBuild, isWindowsStoreBuild } from './util';
const electron = window.require('electron'); // eslint-disable-line
const os = window.require('os');
const ReactSwal = withReactContent(Swal);
// eslint-disable-next-line import/prefer-default-export
export function openSendReportDialog(err, state) {
const reportInstructions = isStoreBuild
? <p><Trans>Please send an email to <span style={{ fontWeight: 'bold' }} role="button" onClick={() => electron.shell.openExternal('mailto:losslesscut@yankee.no')}>losslesscut@yankee.no</span> where you describe what you were doing.</Trans></p>
: <p><Trans>Please create an issue at <span style={{ fontWeight: 'bold' }} role="button" onClick={() => electron.shell.openExternal('https://github.com/mifi/lossless-cut/issues')}>https://github.com/mifi/lossless-cut/issues</span> where you describe what you were doing.</Trans></p>;
? <p><Trans>Please send an email to <span style={{ fontWeight: 'bold' }} role="button" onClick={() => window.util.openExternal('mailto:losslesscut@yankee.no')}>losslesscut@yankee.no</span> where you describe what you were doing.</Trans></p>
: <p><Trans>Please create an issue at <span style={{ fontWeight: 'bold' }} role="button" onClick={() => window.util.openExternal('https://github.com/mifi/lossless-cut/issues')}>https://github.com/mifi/lossless-cut/issues</span> where you describe what you were doing.</Trans></p>;
const platform = os.platform();
const version = electron.remote.app.getVersion();
const { platform } = window.util;
const version = window.util.getAppVersion();
const text = `${err ? err.stack : 'No error'}\n\n${JSON.stringify({
err: err && {

Wyświetl plik

@ -2,62 +2,21 @@ import Swal from 'sweetalert2';
import i18n from 'i18next';
import lodashTemplate from 'lodash/template';
const path = window.require('path');
const fs = window.require('fs-extra');
const open = window.require('open');
const os = window.require('os');
export const {
isWindows,
getOutDir,
getOutFileExtension,
transferTimestamps,
dirExists,
checkDirWriteAccess,
havePermissionToReadFile,
getOutPath,
getExtensionForFormat,
isMasBuild,
isWindowsStoreBuild,
isStoreBuild,
} = window.util;
export function getOutDir(customOutDir, filePath) {
if (customOutDir) return customOutDir;
if (filePath) return path.dirname(filePath);
return undefined;
}
export function getOutPath(customOutDir, filePath, nameSuffix) {
if (!filePath) return undefined;
const parsed = path.parse(filePath);
return path.join(getOutDir(customOutDir, filePath), `${parsed.name}-${nameSuffix}`);
}
export 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;
}
export 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;
}
export async function dirExists(dirPath) {
return (await fs.exists(dirPath)) && (await fs.lstat(dirPath)).isDirectory();
}
export 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);
}
}
export const toast = Swal.mixin({
toast: true,
@ -73,7 +32,7 @@ export const errorToast = (title) => toast.fire({
export const openDirToast = async ({ dirPath, ...props }) => {
const { value } = await toast.fire({ icon: 'success', ...props, timer: 13000, showConfirmButton: true, confirmButtonText: i18n.t('Show'), showCancelButton: true, cancelButtonText: i18n.t('Close') });
if (value) open(dirPath);
if (value) window.util.open(dirPath);
};
export async function showFfmpegFail(err) {
@ -83,7 +42,7 @@ export async function showFfmpegFail(err) {
export function setFileNameTitle(filePath) {
const appName = 'LosslessCut';
document.title = filePath ? `${appName} - ${path.basename(filePath)}` : appName;
document.title = filePath ? `${appName} - ${window.util.getBaseName(filePath)}` : appName;
}
export function filenamify(name) {
@ -111,29 +70,8 @@ export function doesPlayerSupportFile(streams) {
return videoStreams.some(s => !['hevc', 'prores', 'mpeg4'].includes(s.codec_name));
}
export const isMasBuild = window.process.mas;
export const isWindowsStoreBuild = window.process.windowsStore;
export const isStoreBuild = isMasBuild || isWindowsStoreBuild;
export const isDurationValid = (duration) => Number.isFinite(duration) && duration > 0;
const platform = os.platform();
export const isWindows = platform === 'win32';
export function getExtensionForFormat(format) {
const ext = {
matroska: 'mkv',
ipod: 'm4a',
}[format];
return ext || format;
}
export function getOutFileExtension({ isCustomFormatSelected, outFormat, filePath }) {
return isCustomFormatSelected ? `.${getExtensionForFormat(outFormat)}` : path.extname(filePath);
}
// eslint-disable-next-line no-template-curly-in-string
export const defaultOutSegTemplate = '${FILENAME}-${CUT_FROM}-${CUT_TO}${SEG_SUFFIX}${EXT}';
@ -143,3 +81,20 @@ export function generateSegFileName({ template, inputFileNameWithoutExt, segSuff
}
export const hasDuplicates = (arr) => new Set(arr).size !== arr.length;
export function openAbout() {
Swal.fire({
icon: 'info',
title: 'About LosslessCut',
text: `You are running version ${window.util.getAppVersion()}`,
});
}
export function isCuttingStart(cutFrom) {
return cutFrom > 0;
}
export function isCuttingEnd(cutTo, duration) {
if (!isDurationValid(duration)) return true;
return cutTo < duration;
}

Wyświetl plik

@ -4678,11 +4678,6 @@ electron-devtools-installer@^2.2.4:
rimraf "^2.5.2"
semver "^5.3.0"
electron-is-dev@^0.1.2:
version "0.1.2"
resolved "https://registry.npmjs.org/electron-is-dev/-/electron-is-dev-0.1.2.tgz#8a1043e32b3a1da1c3f553dce28ce764246167e3"
integrity sha1-ihBD4ys6HaHD9VPc4oznZCRhZ+M=
electron-is-dev@^1.0.1:
version "1.2.0"
resolved "https://registry.yarnpkg.com/electron-is-dev/-/electron-is-dev-1.2.0.tgz#2e5cea0a1b3ccf1c86f577cee77363ef55deb05e"
@ -5250,7 +5245,7 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
eventemitter3@^4.0.0:
eventemitter3@^4.0.0, eventemitter3@^4.0.7:
version "4.0.7"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==